Backfill same-day snapshots for newly added or restored videos, preserve successful fan snapshots on resume, persist incomplete state before forced re-runs, and split collection completion from backup completion so failed backups can retry without API calls. Reject missing data payloads instead of treating them as empty folders, guard against mass-unfav on suspicious empty remote folders, rebuild Bilibili clients when credentials change, recompute primary folder ownership from active relations with per-folder fav_time, preserve original titles for dropped invalid videos, poll config every 60 seconds, and fix favsort verify interval and mixed resource types.
313 lines
12 KiB
Python
313 lines
12 KiB
Python
# -*- 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, fav_time=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=fav_time,
|
|
)
|
|
|
|
|
|
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 test_primary_folder_is_order_independent():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
db, repo = setup(tmp)
|
|
try:
|
|
repo.upsert_from_fav(resource(1, fav_time=100), F1.media_id, was_added=True)
|
|
assert repo.get_video(1)["folder_id"] == F1.media_id
|
|
repo.upsert_from_fav(resource(1, fav_time=200), F2.media_id, was_added=True)
|
|
assert repo.get_video(1)["folder_id"] == F2.media_id
|
|
|
|
# 全量刷新按相反顺序遍历,主归属仍应指向 fav_time 更新的夹
|
|
repo.upsert_from_fav(resource(1, fav_time=100), F1.media_id, was_added=False)
|
|
assert repo.get_video(1)["folder_id"] == F2.media_id
|
|
repo.upsert_from_fav(resource(1, fav_time=200), F2.media_id, was_added=False)
|
|
assert repo.get_video(1)["folder_id"] == F2.media_id
|
|
|
|
# 从主夹移除、另一个夹仍持有时,主归属切换
|
|
status = repo.mark_resource_removed(F2.media_id, 1)
|
|
assert status == "active"
|
|
assert repo.get_video(1)["folder_id"] == F1.media_id
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_dropped_invalid_preserves_original_title():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
db, repo = setup(tmp)
|
|
try:
|
|
repo.upsert_from_fav(
|
|
resource(1, title="原始标题"), F1.media_id, was_added=True
|
|
)
|
|
repo.set_video_status(1, "dropped")
|
|
repo.upsert_from_fav(
|
|
resource(1, title="已失效视频", attr=9),
|
|
F1.media_id,
|
|
was_added=False,
|
|
)
|
|
video = repo.get_video(1)
|
|
assert video["status"] == "dropped"
|
|
assert video["title"] == "原始标题"
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_pending_snapshot_aids():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
db, repo = setup(tmp)
|
|
try:
|
|
repo.upsert_from_fav(resource(1), F1.media_id, was_added=True)
|
|
repo.upsert_from_fav(resource(2), F1.media_id, was_added=True)
|
|
repo.upsert_from_fav(resource(3), F1.media_id, was_added=True)
|
|
repo.set_video_status(3, "unfaved")
|
|
repo.upsert_snapshot(
|
|
"2026-10-31", 1,
|
|
metrics={"view": 1, "like": 1, "favorite": 1, "coin": 1,
|
|
"share": 1, "reply": 1, "danmaku": 1},
|
|
ok=True, err=None, captured_at=1,
|
|
)
|
|
repo.upsert_snapshot(
|
|
"2026-10-31", 2,
|
|
metrics=None, ok=False, err="x", captured_at=1,
|
|
)
|
|
assert repo.pending_snapshot_aids("2026-10-31") == [2, 3]
|
|
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()
|
|
test_primary_folder_is_order_independent()
|
|
test_dropped_invalid_preserves_original_title()
|
|
test_pending_snapshot_aids()
|
|
print("OK: 收藏夹/视频状态机与黑名单测试全部通过")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|