# -*- coding: utf-8 -*- """入库(候选确认 / 手动)、忽略、拉黑动作测试(假只读客户端,不联网): uv run python tests/test_library.py """ import json 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 fixtures import view_for # noqa: E402 from reaction_board.bilibili.client import BilibiliError # noqa: E402 from reaction_board.bilibili.models import VideoDetail # noqa: E402 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.library import LibraryService # noqa: E402 from reaction_board.timeutil import shanghai_date # noqa: E402 NOW = 1_793_444_393 DATE = shanghai_date(NOW) BV = "BV1xvxi6LEZD" def detail( aid, *, bvid=BV, title="咻咻满 reaction", up_mid=100, up_name="UP100", pubdate=NOW - 3600, view=819, duration=9504, pages=2, ): payload = view_for(aid, view=view) data = payload["data"] data["bvid"] = bvid data["title"] = title data["pubdate"] = pubdate data["ctime"] = pubdate data["duration"] = duration data["owner"] = {"mid": up_mid, "name": up_name, "face": "http://i1.hdslb.com/bfs/face/u.jpg"} data["pages"] = [{"cid": 1, "page": i + 1} for i in range(pages)] return VideoDetail.from_api(data) class FakeReadClient: def __init__(self, by_bvid=None, by_aid=None, short_map=None, fail_bvids=()): self.by_bvid = by_bvid or {} self.by_aid = by_aid or {} self.short_map = short_map or {} self.fail_bvids = set(fail_bvids) def view_detail(self, *, aid=None, bvid=None): if bvid is not None: if bvid in self.fail_bvids: raise BilibiliError(f"view 失败: {bvid}") if bvid in self.by_bvid: return self.by_bvid[bvid] raise BilibiliError(f"未知 bvid: {bvid}") if aid in self.by_aid: return self.by_aid[aid] raise BilibiliError(f"未知 aid: {aid}") def resolve_short_link(self, url): if url not in self.short_map: raise BilibiliError(f"未知短链: {url}") return self.short_map[url] class FakeProvider: def __init__(self, client): self.client = client def get(self): return self.client class FakeUpProfile: def __init__(self): self.calls = [] def capture(self, mids, *, names=None, live_since=None, backfilled=False): self.calls.append((list(mids), dict(names or {}))) class Result: requested = len(mids) updated_videos = len(mids) failed = 0 return Result() def write_config(tmp): data = { "bilibili": { "read": {"mid": 1, "csrf": "r", "cookie": "read-cookie"}, "write": {"mid": 2, "csrf": "w", "cookie": "write-cookie"}, }, "storage": { "database_path": str(Path(tmp) / "app.db"), "backup_dir": str(Path(tmp) / "backup"), }, "discover": {"keywords": ["x"], "pages": 1, "window_days": 14}, } path = Path(tmp) / "config.json" path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") return path def setup(tmp, client, up_profile=None): store = ConfigStore(write_config(tmp), env={}) db = Database(Path(tmp) / "app.db", backup_dir=Path(tmp) / "backup") db.initialize() repo = Repository(db, clock=lambda: NOW) service = LibraryService( store, repo, FakeProvider(client), up_profile_service=up_profile, clock=lambda: NOW, ) return db, repo, service def add_candidate(repo, aid, *, up_mid=100, pubdate=NOW - 3600): return repo.upsert_candidate( aid=aid, bvid=BV, title="咻咻满 reaction", up_mid=up_mid, up_name=f"UP{up_mid}", pubdate=pubdate, cover=None, score=3, channel="search:test#p1", now=NOW, ) def test_ingest_candidate_added_and_snapshot(): with tempfile.TemporaryDirectory() as tmp: ups = FakeUpProfile() client = FakeReadClient(by_aid={9: detail(9)}) db, repo, service = setup(tmp, client, ups) try: add_candidate(repo, 9) result = service.ingest_candidate(9) assert result.ok is True and result.state == "added" assert result.aid == 9 assert repo.get_candidate(9) is None video = repo.get_video(9) assert video["status"] == "active" assert video["source"] == "candidate:telegram" assert video["folder_id"] is None assert video["up_mid"] == 100 assert video["up_face"] == "http://i1.hdslb.com/bfs/face/u.jpg" snap = repo.get_snapshot(DATE, 9) assert snap is not None and snap["ok"] == 1 and snap["view"] == 819 assert ups.calls == [([100], {100: "UP100"})] finally: db.close() def test_ingest_candidate_exists_and_missing(): with tempfile.TemporaryDirectory() as tmp: client = FakeReadClient(by_aid={9: detail(9)}) db, repo, service = setup(tmp, client) try: add_candidate(repo, 9) assert service.ingest_candidate(9).state == "added" # 候选已被删除,但视频已在库 again = service.ingest_candidate(9) assert again.ok is True and again.state == "exists" missing = service.ingest_candidate(999) assert missing.ok is False and missing.state == "missing" finally: db.close() def test_ingest_candidate_recovers_dropped(): with tempfile.TemporaryDirectory() as tmp: client = FakeReadClient(by_aid={9: detail(9)}) db, repo, service = setup(tmp, client) try: add_candidate(repo, 9) assert service.ingest_candidate(9).state == "added" repo.set_video_status(9, "dropped", now=NOW) add_candidate(repo, 9) result = service.ingest_candidate(9) assert result.ok is True and result.state == "recovered" assert repo.get_video(9)["status"] == "active" finally: db.close() def test_ingest_candidate_falls_back_when_detail_fails(): with tempfile.TemporaryDirectory() as tmp: client = FakeReadClient(fail_bvids={BV}) db, repo, service = setup(tmp, client) try: add_candidate(repo, 9) result = service.ingest_candidate(9) assert result.ok is True and result.state == "added" assert repo.get_video(9)["title"] == "咻咻满 reaction" assert repo.get_snapshot(DATE, 9) is None finally: db.close() def test_manual_ingest_forms_and_multiple(): with tempfile.TemporaryDirectory() as tmp: ups = FakeUpProfile() client = FakeReadClient( by_bvid={BV: detail(9)}, by_aid={12345: detail(77, bvid="BV77", up_mid=200, up_name="UP200")}, short_map={"https://b23.tv/abc": f"https://www.bilibili.com/video/{BV}"}, ) db, repo, service = setup(tmp, client, ups) try: result = service.ingest_text( f"看这个 https://www.bilibili.com/video/{BV} 还有 av12345" ) assert result.refs == 2 and result.added == 2 assert [r.aid for r in result.results] == [9, 77] assert repo.get_video(9)["source"] == "manual:telegram" assert repo.get_video(77)["status"] == "active" short = service.ingest_text("https://b23.tv/abc") assert short.added == 0 assert short.results[0].state == "exists" none = service.ingest_text("没有视频") assert none.refs == 0 and none.results == [] finally: db.close() def test_manual_ingest_error_isolation(): with tempfile.TemporaryDirectory() as tmp: client = FakeReadClient( by_bvid={BV: detail(9)}, fail_bvids={"BVfail000000"}, ) db, repo, service = setup(tmp, client) try: result = service.ingest_text(f"{BV} BVfail000000") assert result.added == 1 states = [r.state for r in result.results] assert states == ["added", "error"] finally: db.close() def test_snapshot_not_overwritten(): with tempfile.TemporaryDirectory() as tmp: client = FakeReadClient(by_bvid={BV: detail(9, view=819)}) db, repo, service = setup(tmp, client) try: repo.upsert_snapshot( DATE, 9, metrics={"view": 5000}, ok=True, err=None, captured_at=NOW, ) service.ingest_text(BV) snap = repo.get_snapshot(DATE, 9) assert snap["view"] == 5000 # 已有成功快照不覆盖 finally: db.close() def test_ignore_and_blacklist(): with tempfile.TemporaryDirectory() as tmp: client = FakeReadClient() db, repo, service = setup(tmp, client) try: add_candidate(repo, 11, up_mid=300) add_candidate(repo, 12, up_mid=300) assert service.ignore(11).state == "rejected" assert repo.get_candidate(11)["decision"] == "rejected" result = service.blacklist(12) assert result.ok is True assert repo.is_blacklisted(300) assert repo.get_candidate(12)["decision"] == "rejected" assert service.ignore(999).state == "missing" finally: db.close() def main(): test_ingest_candidate_added_and_snapshot() test_ingest_candidate_exists_and_missing() test_ingest_candidate_recovers_dropped() test_ingest_candidate_falls_back_when_detail_fails() test_manual_ingest_forms_and_multiple() test_manual_ingest_error_isolation() test_snapshot_not_overwritten() test_ignore_and_blacklist() print("OK: 入库、手动入库、忽略、拉黑测试全部通过") if __name__ == "__main__": main()