# -*- coding: utf-8 -*- """UP 粉丝快照采集测试(不联网): uv run python tests/test_up_profile.py""" import os import sys import tempfile from pathlib import Path from types import SimpleNamespace 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 from reaction_board.services.up_profile import UpProfileService # noqa: E402 FOLDER = FolderConfig(year=2026, media_id=3700023351, title="2026") NOW = 1_800_000_000 def resource(aid, up_mid): return FavResource( aid=aid, bvid=f"BV{aid}", title=f"视频{aid}", up_mid=up_mid, up_name=f"UP{up_mid}", pubdate=NOW - 86400, duration=7200, cover=None, attr=0, fav_time=None, pages=2, ) class FakeClient: def __init__(self, *, failing=()): self.calls = [] self.failing = set(failing) def relation_stat(self, mid): self.calls.append(int(mid)) if int(mid) in self.failing: raise RuntimeError("simulated network error") return SimpleNamespace(mid=int(mid), follower=1000 + int(mid), following=5) def setup(tmp): db = Database(Path(tmp) / "app.db") db.initialize() repo = Repository(db, clock=lambda: NOW) repo.ensure_folder(FOLDER) for aid, mid in ((1, 100), (2, 100), (3, 200)): repo.upsert_from_fav(resource(aid, mid), FOLDER.media_id, was_added=True) return db, repo def test_capture_dedupes_and_continues_after_failure(): with tempfile.TemporaryDirectory() as tmp: db, repo = setup(tmp) try: client = FakeClient(failing={200}) service = UpProfileService(repo, lambda: client, clock=lambda: NOW) result = service.capture( [100, 100, 200, None, 0], live_since=NOW - 3600 ) assert result.requested == 2 assert client.calls == [100, 200] assert result.updated_videos == 2 assert result.failed == 1 assert "200" in result.errors[0] assert repo.get_video(1)["up_follower"] == 1100 assert repo.get_video(2)["up_follower"] == 1100 assert repo.get_video(3)["up_follower"] is None assert repo.get_up_profile(100)["follower_first"] == 1100 assert repo.get_up_profile(200) is None finally: db.close() class BlockingClient: """模拟 DNS/传输层挂起:请求永不返回。""" def __init__(self, *, block=()): self.calls = [] self.block = set(block) def relation_stat(self, mid): self.calls.append(int(mid)) if int(mid) in self.block: time.sleep(5) raise AssertionError("不应返回") return SimpleNamespace(mid=int(mid), follower=1000 + int(mid), following=5) def test_capture_times_out_and_continues(): with tempfile.TemporaryDirectory() as tmp: db, repo = setup(tmp) try: client = BlockingClient(block={100}) service = UpProfileService( repo, lambda: client, clock=lambda: NOW, request_timeout=0.05, heartbeat=1, ) result = service.capture([100, 200]) assert result.requested == 2 assert result.failed == 1 assert "timeout" in result.errors[0] # 卡住的 UP 不影响后续 assert repo.get_video(3)["up_follower"] == 1200 assert repo.get_video(1)["up_follower"] is None finally: db.close() def test_capture_marks_old_videos_as_backfilled(): with tempfile.TemporaryDirectory() as tmp: db, repo = setup(tmp) try: client = FakeClient() service = UpProfileService(repo, lambda: client, clock=lambda: NOW) service.capture([100], live_since=NOW + 1) assert repo.get_video(1)["up_follower_backfilled"] == 1 assert repo.get_video(2)["up_follower_backfilled"] == 1 finally: db.close() def test_backfill_dry_run_and_limit(): with tempfile.TemporaryDirectory() as tmp: db, repo = setup(tmp) try: client = FakeClient() service = UpProfileService(repo, lambda: client, clock=lambda: NOW) dry = service.backfill(dry_run=True) assert dry.requested == 2 assert client.calls == [] assert repo.up_follower_coverage()["missing"] == 3 limited = service.backfill(limit=1) assert limited.requested == 1 assert client.calls == [100] assert repo.get_video(1)["up_follower_backfilled"] == 1 assert repo.get_video(3)["up_follower"] is None rest = service.backfill() assert rest.requested == 1 assert client.calls == [100, 200] assert repo.up_follower_coverage()["missing"] == 0 finally: db.close() def main(): test_capture_dedupes_and_continues_after_failure() test_capture_marks_old_videos_as_backfilled() test_backfill_dry_run_and_limit() print("OK: UP 粉丝快照采集与回填测试全部通过") if __name__ == "__main__": main()