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,243 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""收藏夹/视频状态机测试(不联网): uv run python tests/test_repository.py"""
|
||||
|
||||
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 FavResource # noqa: E402
|
||||
from reaction_board.config import FolderConfig # noqa: E402
|
||||
from reaction_board.database import Database # noqa: E402
|
||||
from reaction_board.repository import Repository # noqa: E402
|
||||
|
||||
F1 = FolderConfig(year=2025, media_id=4451053851, title="2025")
|
||||
F2 = FolderConfig(year=2026, media_id=3700023351, title="2026")
|
||||
|
||||
|
||||
def resource(aid, *, attr=0, up_mid=100, title="视频", bvid=None):
|
||||
return FavResource(
|
||||
aid=aid,
|
||||
bvid=bvid or f"BV{aid}",
|
||||
title=title,
|
||||
up_mid=up_mid,
|
||||
up_name="UP",
|
||||
pubdate=1_700_000_000,
|
||||
duration=600,
|
||||
cover="http://example/cover.jpg",
|
||||
attr=attr,
|
||||
fav_time=None,
|
||||
)
|
||||
|
||||
|
||||
def setup(tmp):
|
||||
db = Database(Path(tmp) / "app.db", backup_dir=Path(tmp) / "backup")
|
||||
db.initialize()
|
||||
repo = Repository(db, clock=lambda: 1_800_000_000)
|
||||
repo.ensure_folder(F1)
|
||||
repo.ensure_folder(F2)
|
||||
return db, repo
|
||||
|
||||
|
||||
def test_upsert_and_folder_resource():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db, repo = setup(tmp)
|
||||
try:
|
||||
outcome = repo.upsert_from_fav(resource(1), F1.media_id, was_added=True)
|
||||
assert outcome.status == "active"
|
||||
assert outcome.previous_folder_id is None
|
||||
video = repo.get_video(1)
|
||||
assert video["bvid"] == "BV1"
|
||||
assert video["folder_id"] == F1.media_id
|
||||
assert video["source"] == f"fav:{F1.media_id}"
|
||||
assert video["first_seen"] == 1_800_000_000
|
||||
assert repo.folder_active_aids(F1.media_id) == {1}
|
||||
|
||||
# 幂等:重复导入不新增行、不改 first_seen
|
||||
repo.upsert_from_fav(resource(1, title="新标题"), F1.media_id, was_added=False)
|
||||
assert len(repo.list_videos()) == 1
|
||||
video = repo.get_video(1)
|
||||
assert video["title"] == "新标题"
|
||||
assert video["first_seen"] == 1_800_000_000
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_invalid_attr_and_missing():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db, repo = setup(tmp)
|
||||
try:
|
||||
out = repo.upsert_from_fav(resource(2, attr=9), F1.media_id, was_added=True)
|
||||
assert out.status == "removed"
|
||||
assert repo.get_video(2)["attr"] == 9
|
||||
|
||||
# 原本有效的视频后来失效:保留原历史标题
|
||||
repo.upsert_from_fav(
|
||||
resource(4, title="原始标题"), F1.media_id, was_added=True
|
||||
)
|
||||
repo.upsert_from_fav(
|
||||
resource(4, title="已失效视频", attr=9), F1.media_id, was_added=False
|
||||
)
|
||||
video4 = repo.get_video(4)
|
||||
assert video4["status"] == "removed"
|
||||
assert video4["title"] == "原始标题"
|
||||
|
||||
status = repo.mark_missing_resource(
|
||||
3, folder_id=F1.media_id, bvid="BV3"
|
||||
)
|
||||
assert status == "removed"
|
||||
video = repo.get_video(3)
|
||||
assert video["status"] == "removed"
|
||||
assert video["bvid"] == "BV3"
|
||||
assert video["title"] == "(资源已失效)"
|
||||
assert video["attr"] == 9
|
||||
assert 3 in repo.folder_active_aids(F1.media_id)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_remove_from_folder_transitions():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db, repo = setup(tmp)
|
||||
try:
|
||||
for aid in (1, 2):
|
||||
repo.upsert_from_fav(resource(aid), F1.media_id, was_added=True)
|
||||
# 2 同时出现在两个夹:从 F1 移除后仍 active
|
||||
repo.upsert_from_fav(resource(2), F2.media_id, was_added=True)
|
||||
status = repo.mark_resource_removed(F1.media_id, 2)
|
||||
assert status == "active"
|
||||
assert repo.get_video(2)["status"] == "active"
|
||||
|
||||
# 1 只在 F1:移除 -> unfaved,但数据保留
|
||||
status = repo.mark_resource_removed(F1.media_id, 1)
|
||||
assert status == "unfaved"
|
||||
assert repo.get_video(1)["status"] == "unfaved"
|
||||
assert repo.get_video(1)["bvid"] == "BV1"
|
||||
assert 1 not in repo.folder_active_aids(F1.media_id)
|
||||
|
||||
# 再次出现在收藏夹 -> active
|
||||
out = repo.upsert_from_fav(resource(1), F1.media_id, was_added=True)
|
||||
assert out.status == "active"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_dropped_only_revives_on_reappearing_in_folder():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db, repo = setup(tmp)
|
||||
try:
|
||||
repo.upsert_from_fav(resource(1), F1.media_id, was_added=True)
|
||||
repo.set_video_status(1, "dropped")
|
||||
# 全量刷新(未重新加入)保持 dropped
|
||||
out = repo.upsert_from_fav(resource(1), F1.media_id, was_added=False)
|
||||
assert out.status == "dropped"
|
||||
# 从收藏夹移除也不改变 dropped
|
||||
repo.mark_resource_removed(F1.media_id, 1)
|
||||
assert repo.get_video(1)["status"] == "dropped"
|
||||
# 重新出现在收藏夹 -> 自动恢复 active
|
||||
out = repo.upsert_from_fav(resource(1), F1.media_id, was_added=True)
|
||||
assert out.status == "active"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_removed_stays_removed_when_folder_removed():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db, repo = setup(tmp)
|
||||
try:
|
||||
repo.upsert_from_fav(resource(1, attr=9), F1.media_id, was_added=True)
|
||||
assert repo.get_video(1)["status"] == "removed"
|
||||
status = repo.mark_resource_removed(F1.media_id, 1)
|
||||
assert status == "removed"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_multi_folder_moves_primary_and_warns():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db, repo = setup(tmp)
|
||||
try:
|
||||
repo.upsert_from_fav(resource(1), F1.media_id, was_added=True)
|
||||
out = repo.upsert_from_fav(resource(1), F2.media_id, was_added=True)
|
||||
assert out.moved_folder is True
|
||||
assert out.previous_folder_id == F1.media_id
|
||||
assert repo.get_video(1)["folder_id"] == F2.media_id
|
||||
# 两个夹的资源关系都保留
|
||||
assert repo.folder_active_aids(F1.media_id) == {1}
|
||||
assert repo.folder_active_aids(F2.media_id) == {1}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_blacklist_seed_idempotent():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db, repo = setup(tmp)
|
||||
try:
|
||||
assert repo.seed_blacklist([1, 2, 3], now=100) == 3
|
||||
assert repo.seed_blacklist([2, 3, 4], now=200) == 1
|
||||
rows = repo.blacklist()
|
||||
assert [r["mid"] for r in rows] == [1, 2, 3, 4]
|
||||
assert all(r["reason"] == "seed" for r in rows)
|
||||
assert repo.is_blacklisted(2) and not repo.is_blacklisted(99)
|
||||
repo.add_blacklist(9, name="切片号", reason="manual")
|
||||
assert repo.is_blacklisted(9)
|
||||
repo.add_blacklist(9, reason="updated")
|
||||
row = next(r for r in repo.blacklist() if r["mid"] == 9)
|
||||
assert row["name"] == "切片号"
|
||||
assert row["reason"] == "updated"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_folder_sync_metadata_and_counts():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db, repo = setup(tmp)
|
||||
try:
|
||||
repo.upsert_from_fav(resource(1), F1.media_id, was_added=True)
|
||||
repo.finish_folder_sync(
|
||||
F1, media_count=1, full_refresh=True, now=1_800_000_000
|
||||
)
|
||||
row = repo.get_folder(F1.media_id)
|
||||
assert row.media_count == 1
|
||||
assert row.last_ids_at == 1_800_000_000
|
||||
assert row.last_full_at == 1_800_000_000
|
||||
|
||||
repo.finish_folder_sync(
|
||||
F1, media_count=2, full_refresh=False, now=1_800_000_100
|
||||
)
|
||||
row = repo.get_folder(F1.media_id)
|
||||
assert row.media_count == 2
|
||||
assert row.last_ids_at == 1_800_000_100
|
||||
assert row.last_full_at == 1_800_000_000
|
||||
|
||||
# 配置中移除的年度夹停用,重新加入时恢复
|
||||
repo.disable_folders_except([F1.media_id])
|
||||
assert repo.get_folder(F2.media_id).enabled is False
|
||||
repo.ensure_folder(F2)
|
||||
assert repo.get_folder(F2.media_id).enabled is True
|
||||
|
||||
counts = repo.counts()
|
||||
assert counts["video_total"] == 1
|
||||
assert counts["video_by_status"] == {"active": 1}
|
||||
assert counts["folders"] == 2
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def main():
|
||||
test_upsert_and_folder_resource()
|
||||
test_invalid_attr_and_missing()
|
||||
test_remove_from_folder_transitions()
|
||||
test_dropped_only_revives_on_reappearing_in_folder()
|
||||
test_removed_stays_removed_when_folder_removed()
|
||||
test_multi_folder_moves_primary_and_warns()
|
||||
test_blacklist_seed_idempotent()
|
||||
test_folder_sync_metadata_and_counts()
|
||||
print("OK: 收藏夹/视频状态机与黑名单测试全部通过")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user