feat(ingest): db-only library ingest and telegram manual ingest
This commit is contained in:
1 parent
d2adb65e08
commit
62abbd7ea1
6 files changed
+866
-28
No files matched your search
@@ -0,0 +1,296 @@
|
||||
# -*- 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": ""}
|
||||
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
|
||||
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()
|
||||
+66
-17
@@ -11,11 +11,16 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
from reaction_board.notify.telegram import ( # noqa: E402
|
||||
HELP_TEXT,
|
||||
TelegramClient,
|
||||
TelegramError,
|
||||
format_duration,
|
||||
)
|
||||
from reaction_board.services.favorites import ActionResult # noqa: E402
|
||||
from reaction_board.services.library import ( # noqa: E402
|
||||
ActionResult,
|
||||
IngestResult,
|
||||
ManualIngestResult,
|
||||
)
|
||||
from reaction_board.services.telegram_poller import TelegramPoller # noqa: E402
|
||||
|
||||
|
||||
@@ -58,7 +63,7 @@ def test_send_candidates_and_chunking():
|
||||
keyboard = json.loads(call["data"]["reply_markup"])
|
||||
assert len(keyboard["inline_keyboard"]) == 2
|
||||
assert [button["callback_data"] for button in keyboard["inline_keyboard"][0]] == [
|
||||
"fav:1", "ign:1", "blk:1",
|
||||
"add:1", "ign:1", "blk:1",
|
||||
]
|
||||
|
||||
transport = FakeTransport()
|
||||
@@ -76,7 +81,7 @@ def test_send_candidates_dedupes_by_aid():
|
||||
keyboard = json.loads(transport.calls[0]["data"]["reply_markup"])
|
||||
assert len(keyboard["inline_keyboard"]) == 2
|
||||
assert [row[0]["callback_data"] for row in keyboard["inline_keyboard"]] == [
|
||||
"fav:1", "fav:2",
|
||||
"add:1", "add:2",
|
||||
]
|
||||
|
||||
|
||||
@@ -88,7 +93,7 @@ def test_get_updates_and_answer():
|
||||
assert data["offset"] == 99
|
||||
assert data["timeout"] == 17
|
||||
assert transport.calls[0]["timeout"] == 32
|
||||
assert json.loads(data["allowed_updates"]) == ["callback_query"]
|
||||
assert json.loads(data["allowed_updates"]) == ["callback_query", "message"]
|
||||
|
||||
transport = FakeTransport(result=True)
|
||||
bot = TelegramClient("token", 42, transport=transport)
|
||||
@@ -148,10 +153,13 @@ class FakeBot:
|
||||
class FakeActions:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
self.manual = []
|
||||
|
||||
def favorite(self, aid):
|
||||
self.calls.append(("fav", aid))
|
||||
return ActionResult(True, aid, "favorite", "done", position=3, message="已加入收藏夹")
|
||||
def ingest_candidate(self, aid):
|
||||
self.calls.append(("add", aid))
|
||||
return IngestResult(
|
||||
True, aid, "added", title="咻咻满 reaction", message="已入库"
|
||||
)
|
||||
|
||||
def ignore(self, aid):
|
||||
self.calls.append(("ign", aid))
|
||||
@@ -161,6 +169,16 @@ class FakeActions:
|
||||
self.calls.append(("blk", aid))
|
||||
return ActionResult(True, aid, "blacklist", "rejected", message="已拉黑")
|
||||
|
||||
def ingest_text(self, text):
|
||||
self.manual.append(text)
|
||||
return ManualIngestResult(
|
||||
refs=1, added=1,
|
||||
results=[IngestResult(True, 9, "added", title="咻咻满 reaction", message="已入库")],
|
||||
)
|
||||
|
||||
|
||||
ADD_REPLY = "✅ 已入库《咻咻满 reaction》(aid 9)"
|
||||
|
||||
|
||||
def update(update_id, data, *, chat_id=42, from_id=42):
|
||||
return {
|
||||
@@ -174,33 +192,63 @@ def update(update_id, data, *, chat_id=42, from_id=42):
|
||||
}
|
||||
|
||||
|
||||
def message_update(update_id, text, *, chat_id=42, from_id=42):
|
||||
return {
|
||||
"update_id": update_id,
|
||||
"message": {
|
||||
"message_id": update_id * 10,
|
||||
"chat": {"id": chat_id},
|
||||
"from": {"id": from_id},
|
||||
"text": text,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_poller_dispatch_and_authorization():
|
||||
bot = FakeBot([
|
||||
update(1, "fav:9"),
|
||||
update(1, "add:9"),
|
||||
update(2, "ign:10"),
|
||||
update(3, "blk:11"),
|
||||
update(4, "fav:12", chat_id=999),
|
||||
update(4, "add:12", chat_id=999),
|
||||
update(5, "bad"),
|
||||
update(6, "fav:13"),
|
||||
])
|
||||
actions = FakeActions()
|
||||
poller = TelegramPoller(bot, actions)
|
||||
offset = poller.run_once(None)
|
||||
assert offset == 6
|
||||
assert actions.calls == [("fav", 9), ("ign", 10), ("blk", 11)]
|
||||
assert bot.answers[0] == ("cb1", "已加入收藏夹(位置 #3)")
|
||||
assert offset == 7
|
||||
assert actions.calls == [("add", 9), ("ign", 10), ("blk", 11), ("add", 13)]
|
||||
assert bot.answers[0] == ("cb1", ADD_REPLY)
|
||||
assert bot.answers[1] == ("cb2", "• 已忽略(aid 10)")
|
||||
assert bot.answers[2] == ("cb3", "• 已拉黑(aid 11)")
|
||||
assert bot.answers[3] == ("cb4", "无权限")
|
||||
assert bot.answers[4] == ("cb5", "未知操作")
|
||||
assert bot.edits == [10, 20, 30]
|
||||
assert bot.edits == [10, 20, 30, 60]
|
||||
|
||||
|
||||
def test_answer_failure_falls_back_to_text():
|
||||
bot = FakeBot([update(1, "fav:9")], fail_answer=True)
|
||||
def test_poller_manual_message():
|
||||
bot = FakeBot([
|
||||
message_update(1, "https://www.bilibili.com/video/BV1x8x166EzY"),
|
||||
message_update(2, "hello", chat_id=999),
|
||||
message_update(3, "/help"),
|
||||
])
|
||||
actions = FakeActions()
|
||||
poller = TelegramPoller(bot, actions)
|
||||
poller.run_once(None)
|
||||
assert actions.calls == [("fav", 9)]
|
||||
assert actions.manual == ["https://www.bilibili.com/video/BV1x8x166EzY"]
|
||||
assert "入库" in bot.sent[0]
|
||||
assert HELP_TEXT in bot.sent
|
||||
assert len(bot.sent) == 2
|
||||
|
||||
|
||||
def test_answer_failure_falls_back_to_text():
|
||||
bot = FakeBot([update(1, "add:9")], fail_answer=True)
|
||||
actions = FakeActions()
|
||||
poller = TelegramPoller(bot, actions)
|
||||
poller.run_once(None)
|
||||
assert actions.calls == [("add", 9)]
|
||||
assert bot.answers == []
|
||||
assert bot.sent == ["已加入收藏夹(位置 #3)"]
|
||||
assert bot.sent == [ADD_REPLY]
|
||||
assert bot.edits == [10]
|
||||
|
||||
|
||||
@@ -257,6 +305,7 @@ def main():
|
||||
test_get_updates_and_answer()
|
||||
test_telegram_error_description_preserved()
|
||||
test_poller_dispatch_and_authorization()
|
||||
test_poller_manual_message()
|
||||
test_answer_failure_falls_back_to_text()
|
||||
test_poller_backoff_and_recovery()
|
||||
print("OK: Telegram 消息、分片、长轮询与回调授权测试全部通过")
|
||||
|
||||
Reference in new issue
Block a user