feat(p3): add discovery, telegram notifications, and one-click favorites
Implement WBI video search with legacy fallback, classification scoring, candidate pool, permanent seen-aid dedupe, blacklist filtering, and batched Telegram notifications. Add write client for fav deal/sort, target-folder selection, insertion position calculation, read-back verification, fav_action locking, retry on failure, ignore and blacklist actions. Wire discovery, candidate cleanup, Telegram long polling, and new discover/candidates CLI commands into the scheduler. Add migration v3 for candidate duration and index stats.
This commit is contained in:
1 parent
6a21b5d905
commit
d971e718e9
31 files changed
+2886
-55
No files matched your search
@@ -0,0 +1,194 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""候选池、去重、通知记录与 fav_action 状态机测试(不联网):
|
||||
uv run python tests/test_candidates.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
|
||||
|
||||
FOLDER = FolderConfig(year=2026, media_id=3700023351, title="2026")
|
||||
|
||||
|
||||
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(FOLDER)
|
||||
return db, repo
|
||||
|
||||
|
||||
def candidate(repo, aid=1, *, score=3, now=1_800_000_000):
|
||||
return repo.upsert_candidate(
|
||||
aid=aid,
|
||||
bvid=f"BV{aid}",
|
||||
title=f"咻咻满 reaction {aid}",
|
||||
up_mid=100,
|
||||
up_name="UP",
|
||||
pubdate=1_799_000_000,
|
||||
cover="https://example/cover.jpg",
|
||||
score=score,
|
||||
channel="search:test#p1",
|
||||
now=now,
|
||||
)
|
||||
|
||||
|
||||
def test_seen_and_known_aid():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db, repo = setup(tmp)
|
||||
try:
|
||||
assert repo.known_aid(1) is False
|
||||
repo.record_seen(1, source="search:test#p1")
|
||||
repo.record_seen(1, source="search:test#p2") # 幂等
|
||||
assert repo.known_aid(1) is True
|
||||
repo.upsert_from_fav(
|
||||
FavResource(
|
||||
aid=2, bvid="BV2", title="视频", up_mid=100, up_name="UP",
|
||||
pubdate=1, duration=60, cover=None, attr=0, fav_time=None,
|
||||
),
|
||||
FOLDER.media_id,
|
||||
was_added=True,
|
||||
)
|
||||
assert repo.known_aid(2) is True
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_candidate_upsert_preserves_decision_and_notified():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db, repo = setup(tmp)
|
||||
try:
|
||||
first = candidate(repo, 1, score=2)
|
||||
assert first["decision"] is None and first["notified_at"] is None
|
||||
|
||||
repo.mark_notified(
|
||||
1, channel="telegram", batch_id="b1", ok=True, now=1_800_000_100
|
||||
)
|
||||
repo.mark_candidate_decision(1, "rejected", now=1_800_000_200)
|
||||
|
||||
second = candidate(repo, 1, score=4, now=1_800_000_300)
|
||||
assert second["score"] == 4
|
||||
assert second["decision"] == "rejected"
|
||||
assert second["notified_at"] == 1_800_000_100
|
||||
assert second["first_seen"] == 1_800_000_000
|
||||
assert len(repo.list_candidates()) == 1
|
||||
assert len(repo.list_candidates("rejected")) == 1
|
||||
assert repo.list_candidates("accepted") == []
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_notify_and_reject_for_up():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db, repo = setup(tmp)
|
||||
try:
|
||||
candidate(repo, 1)
|
||||
candidate(repo, 2)
|
||||
repo.mark_notified(
|
||||
1, channel="telegram", batch_id="b1", ok=False, err="boom",
|
||||
now=1_800_000_100,
|
||||
)
|
||||
assert repo.get_candidate(1)["notified_at"] is None
|
||||
repo.mark_notified(
|
||||
1, channel="telegram", batch_id="b2", ok=True, now=1_800_000_200
|
||||
)
|
||||
assert repo.get_candidate(1)["notified_at"] == 1_800_000_200
|
||||
|
||||
assert repo.reject_pending_for_up(100, now=1_800_000_300) == 2
|
||||
assert repo.get_candidate(2)["decision"] == "rejected"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_delete_expired_candidates_only_pending():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db, repo = setup(tmp)
|
||||
try:
|
||||
candidate(repo, 1, now=1_800_000_000)
|
||||
candidate(repo, 2, now=1_800_000_000)
|
||||
candidate(repo, 3, now=1_800_000_000)
|
||||
repo.mark_candidate_decision(3, "rejected")
|
||||
deleted = repo.delete_expired_candidates(
|
||||
ttl_days=7, now=1_800_000_000 + 8 * 86400
|
||||
)
|
||||
assert deleted == 2
|
||||
assert repo.get_candidate(1) is None
|
||||
assert repo.get_candidate(3) is not None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_folder_for_up_uses_earliest_video():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db, repo = setup(tmp)
|
||||
try:
|
||||
for aid, pubdate in ((1, 1_700_000_500), (2, 1_700_000_100)):
|
||||
repo.upsert_from_fav(
|
||||
FavResource(
|
||||
aid=aid, bvid=f"BV{aid}", title="视频", up_mid=100,
|
||||
up_name="UP", pubdate=pubdate, duration=60,
|
||||
cover=None, attr=0, fav_time=None,
|
||||
),
|
||||
FOLDER.media_id,
|
||||
was_added=True,
|
||||
)
|
||||
assert repo.folder_for_up(100) == FOLDER.media_id
|
||||
assert repo.folder_for_up(999) is None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_fav_action_state_machine():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db, repo = setup(tmp)
|
||||
try:
|
||||
token, state = repo.begin_fav_action(1, FOLDER.media_id)
|
||||
assert token == "fav:1"
|
||||
assert state == "processing"
|
||||
|
||||
token2, state2 = repo.begin_fav_action(1, FOLDER.media_id)
|
||||
assert token2 == token and state2 == "busy"
|
||||
|
||||
repo.finish_fav_action(token, position=3)
|
||||
assert repo.get_fav_action(token)["state"] == "done"
|
||||
assert repo.get_fav_action(token)["position"] == 3
|
||||
assert repo.begin_fav_action(1, FOLDER.media_id)[1] == "done"
|
||||
|
||||
token3, state3 = repo.begin_fav_action(2, FOLDER.media_id)
|
||||
assert state3 == "processing"
|
||||
repo.fail_fav_action(token3, error="夹满")
|
||||
assert repo.get_fav_action(token3)["state"] == "failed"
|
||||
assert repo.get_fav_action(token3)["err"] == "夹满"
|
||||
|
||||
# failed 可重试
|
||||
_, retry = repo.begin_fav_action(2, FOLDER.media_id)
|
||||
assert retry == "processing"
|
||||
|
||||
# 过期的 processing 自动失败
|
||||
assert repo.expire_fav_actions(now=1_800_000_000 + 2 * 86400) >= 1
|
||||
assert repo.get_fav_action("fav:2")["state"] == "failed"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def main():
|
||||
test_seen_and_known_aid()
|
||||
test_candidate_upsert_preserves_decision_and_notified()
|
||||
test_notify_and_reject_for_up()
|
||||
test_delete_expired_candidates_only_pending()
|
||||
test_folder_for_up_uses_earliest_video()
|
||||
test_fav_action_state_machine()
|
||||
print("OK: 候选池、去重、通知记录与 fav_action 状态机测试全部通过")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,77 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""候选分类纯函数测试: uv run python tests/test_classify.py"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
from reaction_board.classify import ( # noqa: E402
|
||||
clean_title,
|
||||
contains_any,
|
||||
normalize_title,
|
||||
parse_duration,
|
||||
passes_required_title,
|
||||
score_item,
|
||||
)
|
||||
|
||||
CLASSIFY = {
|
||||
"require_title": "咻咻满",
|
||||
"self_mid": 37754047,
|
||||
"positive": ["reaction", "re", "反应", "看", "听", "初听", "首次", "点评", "锐评", "路人", "震惊", "双人"],
|
||||
"charge": ["充电", "加急", "专场"],
|
||||
"negative": ["歌切", "切片", "纯享", "搬运", "完整版"],
|
||||
"duration_boost": {"gt_30min": 1, "gt_60min": 2},
|
||||
}
|
||||
|
||||
|
||||
def test_title_helpers():
|
||||
assert normalize_title("咻咻满 REACTION") == "咻咻满 reaction"
|
||||
assert clean_title('<em class="keyword">咻咻满</em> reaction') == "咻咻满 reaction"
|
||||
assert passes_required_title("【咻咻满】初听", "咻咻满")
|
||||
assert passes_required_title("咻咻满", "咻咻满")
|
||||
assert not passes_required_title("其他 UP 的 reaction", "咻咻满")
|
||||
assert contains_any("咻咻满 REACTION", ["reaction"])
|
||||
assert not contains_any("咻咻满", [])
|
||||
|
||||
|
||||
def test_parse_duration():
|
||||
assert parse_duration("105:5") == 105 * 60 + 5
|
||||
assert parse_duration("1:02:03") == 3723
|
||||
assert parse_duration("15:30") == 930
|
||||
assert parse_duration(600) == 600
|
||||
assert parse_duration("bad") == 0
|
||||
assert parse_duration(None) == 0
|
||||
assert parse_duration(-5) == 0
|
||||
|
||||
|
||||
def test_score_rules():
|
||||
def score(title, up_mid=100, duration=0):
|
||||
return score_item(
|
||||
title=title, up_mid=up_mid,
|
||||
duration_seconds=duration, classify=CLASSIFY,
|
||||
)
|
||||
|
||||
assert score("咻咻满 reaction", duration=60) == 2
|
||||
assert score("咻咻满 充电", duration=60) == 2
|
||||
assert score("咻咻满 reaction 充电", duration=60) == 4
|
||||
assert score("咻咻满 reaction", duration=61 * 60) == 4
|
||||
assert score("咻咻满 reaction", duration=31 * 60) == 3
|
||||
assert score("咻咻满 reaction", duration=30 * 60) == 2
|
||||
assert score("咻咻满 切片", duration=60) == -5
|
||||
assert score("咻咻满 reaction 切片", duration=60) == -3
|
||||
assert score("咻咻满 reaction", up_mid=37754047, duration=60) == -1
|
||||
assert score("咻咻满", duration=0) == 0
|
||||
assert score("咻咻满", duration=61 * 60) == 2
|
||||
|
||||
|
||||
def main():
|
||||
test_title_helpers()
|
||||
test_parse_duration()
|
||||
test_score_rules()
|
||||
print("OK: 候选标题归一化、时长解析与分类打分测试全部通过")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+56
-1
@@ -51,7 +51,7 @@ def test_init_and_status():
|
||||
path = write_config(tmp)
|
||||
code, output = run_cli(["init", "--config", str(path)])
|
||||
assert code == 0
|
||||
assert '"schema_version": 2' in output
|
||||
assert '"schema_version": 3' in output
|
||||
|
||||
db = Database(Path(tmp) / "app.db")
|
||||
db.initialize()
|
||||
@@ -149,6 +149,14 @@ def test_run_once_offline():
|
||||
def check_login(self):
|
||||
return LoginState(True, 1, "tester", 0, "OK")
|
||||
|
||||
class FakeDiscovery:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def run(self):
|
||||
from reaction_board.services.discovery import DiscoverySummary
|
||||
return DiscoverySummary(started_at=1, finished_at=1)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = write_config(tmp)
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
@@ -157,6 +165,7 @@ def test_run_once_offline():
|
||||
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
original = cli._client
|
||||
original_discovery = cli.DiscoveryService
|
||||
selected = []
|
||||
|
||||
def fake_client(credentials, *args, **kwargs):
|
||||
@@ -164,10 +173,12 @@ def test_run_once_offline():
|
||||
return FakeClient()
|
||||
|
||||
cli._client = fake_client
|
||||
cli.DiscoveryService = FakeDiscovery
|
||||
try:
|
||||
code, output = run_cli(["run", "--once", "--config", str(path)])
|
||||
finally:
|
||||
cli._client = original
|
||||
cli.DiscoveryService = original_discovery
|
||||
assert code == 0
|
||||
assert '"name": "folder_sync"' in output
|
||||
assert '"name": "snapshot"' in output
|
||||
@@ -209,6 +220,49 @@ def test_snapshot_backup_failure_returns_error_and_retries():
|
||||
assert Path(payload["backup_path"]).exists()
|
||||
|
||||
|
||||
def test_candidates_and_discover_commands():
|
||||
from reaction_board.services.discovery import DiscoverySummary
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = write_config(tmp)
|
||||
assert run_cli(["init", "--config", str(path)])[0] == 0
|
||||
db = Database(Path(tmp) / "app.db")
|
||||
db.initialize()
|
||||
try:
|
||||
repo = Repository(db)
|
||||
repo.upsert_candidate(
|
||||
aid=123, bvid="BV123", title="咻咻满 reaction",
|
||||
up_mid=100, up_name="UP", pubdate=1,
|
||||
cover=None, score=3, channel="search:test#p1",
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
code, output = run_cli(["candidates", "--config", str(path)])
|
||||
assert code == 0
|
||||
assert '"count": 1' in output
|
||||
assert '"aid": 123' in output
|
||||
|
||||
class FakeDiscovery:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def run(self):
|
||||
return DiscoverySummary(
|
||||
started_at=1, finished_at=2, candidates=3, notified=1
|
||||
)
|
||||
|
||||
original = cli.DiscoveryService
|
||||
cli.DiscoveryService = FakeDiscovery
|
||||
try:
|
||||
code, output = run_cli(["discover", "--config", str(path)])
|
||||
finally:
|
||||
cli.DiscoveryService = original
|
||||
assert code == 0
|
||||
assert '"candidates": 3' in output
|
||||
assert '"notified": 1' in output
|
||||
|
||||
|
||||
def main_test():
|
||||
test_init_and_status()
|
||||
test_missing_config()
|
||||
@@ -218,6 +272,7 @@ def main_test():
|
||||
test_auth_check_all_and_auth_dispatch()
|
||||
test_run_once_offline()
|
||||
test_snapshot_backup_failure_returns_error_and_retries()
|
||||
test_candidates_and_discover_commands()
|
||||
print("OK: CLI init/sync/snapshot/auth-check/run/status/错误路径测试全部通过")
|
||||
|
||||
|
||||
|
||||
@@ -54,6 +54,9 @@ def test_dual_accounts_and_env_override():
|
||||
assert (cfg.bilibili.write.mid, cfg.bilibili.write.csrf, cfg.bilibili.write.cookie) == (202, "env-write", "env-write-cookie")
|
||||
assert cfg.storage.database_path == Path(tmp) / "custom.db"
|
||||
assert cfg.up_blacklist_seed == (1, 2)
|
||||
assert cfg.discover.keywords == ("咻咻满 reaction", "咻咻满 充电")
|
||||
assert cfg.discover.pages == 2
|
||||
assert cfg.classify["self_mid"] == 37754047
|
||||
|
||||
|
||||
def test_flat_and_missing_account_rejected():
|
||||
|
||||
@@ -24,7 +24,7 @@ def test_write_read_transaction():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db = make_db(tmp)
|
||||
try:
|
||||
assert db.schema_version == 2
|
||||
assert db.schema_version == 3
|
||||
db.write(lambda c: c.execute("INSERT INTO kv(k, v) VALUES ('a', '1')"))
|
||||
assert db.read(lambda c: c.execute("SELECT v FROM kv WHERE k='a'").fetchone()[0]) == "1"
|
||||
|
||||
@@ -94,8 +94,8 @@ def test_close_and_reinitialize():
|
||||
|
||||
db2 = make_db(tmp)
|
||||
try:
|
||||
assert db2.schema_version == 2
|
||||
assert db2.read(lambda c: c.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]) == 2
|
||||
assert db2.schema_version == 3
|
||||
assert db2.read(lambda c: c.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]) == 3
|
||||
finally:
|
||||
db2.close()
|
||||
|
||||
@@ -123,7 +123,7 @@ def test_migration_backup_on_existing_db():
|
||||
db = Database(path, backup_dir=Path(tmp) / "backup")
|
||||
try:
|
||||
applied = db.initialize()
|
||||
assert applied == [1, 2]
|
||||
assert applied == [1, 2, 3]
|
||||
assert len(list((Path(tmp) / "backup").glob("app-pre-v0-*.db"))) == 1
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""发现任务测试(假搜索客户端,不联网): uv run python tests/test_discovery.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 reaction_board.bilibili.client import BilibiliError # noqa: E402
|
||||
from reaction_board.bilibili.search import SearchItem # 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.discovery import DiscoveryService # noqa: E402
|
||||
|
||||
NOW = 1_800_000_000
|
||||
|
||||
|
||||
def write_config(tmp, **discover):
|
||||
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"),
|
||||
},
|
||||
"folders": [{"year": 2026, "media_id": 3700023351, "title": "2026"}],
|
||||
"discover": {
|
||||
"keywords": ["咻咻满 reaction"],
|
||||
"pages": 1,
|
||||
"window_days": 14,
|
||||
**discover,
|
||||
},
|
||||
"classify": {
|
||||
"require_title": "咻咻满",
|
||||
"self_mid": 37754047,
|
||||
"positive": ["reaction"],
|
||||
"charge": ["充电"],
|
||||
"negative": ["切片"],
|
||||
"duration_boost": {"gt_30min": 1, "gt_60min": 2},
|
||||
},
|
||||
"notify": {"min_score": 2, "max_items_per_message": 10},
|
||||
}
|
||||
path = Path(tmp) / "config.json"
|
||||
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
class FakeSearch:
|
||||
def __init__(self, items, error=None):
|
||||
self.items = items
|
||||
self.error = error
|
||||
self.calls = []
|
||||
|
||||
def search(self, keyword, page):
|
||||
self.calls.append((keyword, page))
|
||||
if self.error:
|
||||
raise self.error
|
||||
return list(self.items)
|
||||
|
||||
|
||||
class FakeNotifier:
|
||||
def __init__(self, ok=True):
|
||||
self.ok = ok
|
||||
self.batches = []
|
||||
|
||||
def send_candidates(self, candidates):
|
||||
self.batches.append(list(candidates))
|
||||
return self.ok
|
||||
|
||||
|
||||
def item(aid, *, title="咻咻满 reaction", up_mid=100, pubdate=NOW - 3600,
|
||||
duration=600):
|
||||
return SearchItem(
|
||||
aid=aid,
|
||||
bvid=f"BV{aid}",
|
||||
title=title,
|
||||
up_mid=up_mid,
|
||||
up_name="UP",
|
||||
pubdate=pubdate,
|
||||
duration_seconds=duration,
|
||||
cover="https://example/cover.jpg",
|
||||
)
|
||||
|
||||
|
||||
def make_service(tmp, items, *, notifier=None):
|
||||
path = write_config(tmp)
|
||||
store = ConfigStore(path, env={})
|
||||
db = Database(Path(tmp) / "app.db", backup_dir=Path(tmp) / "backup")
|
||||
db.initialize()
|
||||
repo = Repository(db, clock=lambda: NOW)
|
||||
service = DiscoveryService(
|
||||
store, repo, FakeSearch(items),
|
||||
notifier=notifier,
|
||||
clock=lambda: NOW,
|
||||
sleeper=lambda seconds: None,
|
||||
logger=None,
|
||||
)
|
||||
return db, repo, service
|
||||
|
||||
|
||||
def test_filters_and_scoring():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
notifier = FakeNotifier()
|
||||
db, repo, service = make_service(tmp, [
|
||||
item(1), # 通知
|
||||
item(2, title="咻咻满 reaction 切片"), # 负分丢弃
|
||||
item(3, title="其他 UP reaction"), # 标题不含
|
||||
item(4, pubdate=NOW - 20 * 86400), # 超时间窗
|
||||
item(5, up_mid=999), # 黑名单
|
||||
item(6, title="咻咻满 日常"), # 0 分入池不通知
|
||||
], notifier=notifier)
|
||||
try:
|
||||
repo.seed_blacklist([999], now=NOW)
|
||||
repo.record_seen(1, source="old", now=NOW)
|
||||
# aid=1 已 seen,改为验证已知跳过;另用 7 验证通知
|
||||
summary = service.run()
|
||||
assert summary.items == 6
|
||||
assert summary.known == 1
|
||||
assert summary.title_miss == 1
|
||||
assert summary.old == 1
|
||||
assert summary.blacklisted == 1
|
||||
assert summary.candidates == 1 # aid 6
|
||||
assert summary.notified == 0
|
||||
assert repo.get_candidate(6)["score"] == 0
|
||||
assert repo.get_candidate(2) is None
|
||||
assert repo.get_candidate(4) is None
|
||||
assert repo.get_candidate(5) is None
|
||||
assert notifier.batches == []
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_notification_batch_and_failure():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
notifier = FakeNotifier()
|
||||
db, repo, service = make_service(tmp, [item(1), item(2)], notifier=notifier)
|
||||
try:
|
||||
summary = service.run()
|
||||
assert summary.candidates == 2
|
||||
assert summary.notified == 2
|
||||
assert len(notifier.batches) == 1
|
||||
assert {c["aid"] for c in notifier.batches[0]} == {1, 2}
|
||||
assert repo.get_candidate(1)["notified_at"] == NOW
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
notifier = FakeNotifier(ok=False)
|
||||
db, repo, service = make_service(tmp, [item(3)], notifier=notifier)
|
||||
try:
|
||||
summary = service.run()
|
||||
assert summary.notify_failed == 1
|
||||
assert repo.get_candidate(3)["notified_at"] is None
|
||||
assert repo.get_candidate(3)["decision"] is None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_search_error_does_not_abort_other_keywords():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = write_config(tmp, keywords=["a", "b"])
|
||||
store = ConfigStore(path, env={})
|
||||
db = Database(Path(tmp) / "app.db", backup_dir=Path(tmp) / "backup")
|
||||
db.initialize()
|
||||
repo = Repository(db, clock=lambda: NOW)
|
||||
search = FakeSearch([item(1)], error=BilibiliError("network"))
|
||||
service = DiscoveryService(
|
||||
store, repo, search, clock=lambda: NOW, sleeper=lambda s: None
|
||||
)
|
||||
try:
|
||||
summary = service.run()
|
||||
assert summary.searches == 0
|
||||
assert len(summary.errors) == 2
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_pending_candidate_notified_on_later_run():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db, repo, service = make_service(tmp, [item(1)], notifier=None)
|
||||
try:
|
||||
first = service.run()
|
||||
assert first.candidates == 1
|
||||
assert repo.get_candidate(1)["notified_at"] is None
|
||||
|
||||
notifier = FakeNotifier()
|
||||
service.notifier = notifier
|
||||
second = service.run()
|
||||
assert second.notified == 1
|
||||
assert len(notifier.batches) == 1
|
||||
assert repo.get_candidate(1)["notified_at"] == NOW
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_duplicate_search_results_notified_once():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
notifier = FakeNotifier()
|
||||
db, repo, service = make_service(
|
||||
tmp, [item(1), item(1)], notifier=notifier
|
||||
)
|
||||
try:
|
||||
summary = service.run()
|
||||
assert summary.notified == 1
|
||||
assert len(notifier.batches) == 1
|
||||
assert len(notifier.batches[0]) == 1
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def main():
|
||||
test_filters_and_scoring()
|
||||
test_notification_batch_and_failure()
|
||||
test_search_error_does_not_abort_other_keywords()
|
||||
test_pending_candidate_notified_on_later_run()
|
||||
test_duplicate_search_results_notified_once()
|
||||
print("OK: 发现过滤、打分、候选池与通知批次测试全部通过")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,80 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""新视频插入位置纯函数测试: uv run python tests/test_fav_position.py"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
from reaction_board.fav_position import VideoMeta, plan_insertion # noqa: E402
|
||||
|
||||
|
||||
def meta(up_mid, pubdate):
|
||||
return VideoMeta(up_mid=up_mid, pubdate=pubdate)
|
||||
|
||||
|
||||
def test_empty_and_single():
|
||||
assert plan_insertion([], 9, {}, meta(100, 200)).moves == ()
|
||||
assert plan_insertion([], 9, {}, meta(100, 200)).target_position == 1
|
||||
plan = plan_insertion([1], 9, {1: meta(200, 50)}, meta(100, 200))
|
||||
assert plan.target_position == 1
|
||||
assert plan.moves == ((1, 9), (9, 1))
|
||||
|
||||
|
||||
def test_same_up_group():
|
||||
current = [1, 2, 3, 4]
|
||||
metadata = {
|
||||
1: meta(100, 100),
|
||||
2: meta(100, 90),
|
||||
3: meta(200, 80),
|
||||
4: meta(200, 70),
|
||||
}
|
||||
# 同 UP 组在中间:插到组头
|
||||
plan = plan_insertion(current, 9, metadata, meta(200, 95))
|
||||
assert plan.target_position == 3
|
||||
assert plan.moves == ((2, 9),)
|
||||
|
||||
# 同 UP 组在首位:两次移动
|
||||
plan = plan_insertion(current, 9, metadata, meta(100, 200))
|
||||
assert plan.target_position == 1
|
||||
assert plan.moves == ((1, 9), (9, 1))
|
||||
|
||||
|
||||
def test_new_group_by_earliest_pubdate():
|
||||
current = [1, 2, 3, 4]
|
||||
metadata = {
|
||||
1: meta(100, 100),
|
||||
2: meta(100, 90),
|
||||
3: meta(200, 80),
|
||||
4: meta(200, 70),
|
||||
}
|
||||
# 新 UP 的组键 85,应插到组键 70 的组之前
|
||||
plan = plan_insertion(current, 9, metadata, meta(300, 85))
|
||||
assert plan.target_position == 3
|
||||
assert plan.moves == ((2, 9),)
|
||||
|
||||
# 比所有组都旧:追加到末尾
|
||||
plan = plan_insertion(current, 9, metadata, meta(300, 60))
|
||||
assert plan.target_position == 5
|
||||
assert plan.moves == ((4, 9),)
|
||||
|
||||
|
||||
def test_excludes_new_aid_if_present():
|
||||
current = [1, 9, 2]
|
||||
metadata = {1: meta(100, 100), 2: meta(100, 90)}
|
||||
plan = plan_insertion(current, 9, metadata, meta(100, 95))
|
||||
assert plan.target_position == 1
|
||||
assert plan.moves == ((1, 9), (9, 1))
|
||||
|
||||
|
||||
def main():
|
||||
test_empty_and_single()
|
||||
test_same_up_group()
|
||||
test_new_group_by_earliest_pubdate()
|
||||
test_excludes_new_aid_if_present()
|
||||
print("OK: 新视频插入位置计算测试全部通过")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,211 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""收藏/忽略/拉黑动作测试(假写客户端,不联网): uv run python tests/test_favorites.py"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
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.client import BilibiliAPIError # noqa: E402
|
||||
from reaction_board.bilibili.models import FavResource # 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.favorites import FavoriteActionService # noqa: E402
|
||||
|
||||
NOW = 1_793_444_393
|
||||
FOLDER_ID = 3700023351
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeFavId:
|
||||
aid: int
|
||||
type: int = 2
|
||||
|
||||
|
||||
class FakeWriteClient:
|
||||
def __init__(self, order, *, fail_deal=False):
|
||||
self.order = list(order)
|
||||
self.fail_deal = fail_deal
|
||||
self.dealt = []
|
||||
self.sorts = []
|
||||
|
||||
def fav_ids(self, media_id):
|
||||
return [FakeFavId(aid) for aid in self.order]
|
||||
|
||||
def deal(self, aid, folder_id):
|
||||
if self.fail_deal:
|
||||
raise BilibiliAPIError(-1, "收藏夹已满")
|
||||
self.dealt.append((aid, folder_id))
|
||||
self.order.append(aid)
|
||||
|
||||
def sort(self, media_id, spec):
|
||||
predecessor, _, moved, _ = (int(part) for part in spec.split(":"))
|
||||
self.sorts.append(spec)
|
||||
self.order.remove(moved)
|
||||
index = self.order.index(predecessor)
|
||||
self.order.insert(index + 1, moved)
|
||||
|
||||
|
||||
class FakeProvider:
|
||||
def __init__(self, client):
|
||||
self.client = client
|
||||
|
||||
def get(self):
|
||||
return self.client
|
||||
|
||||
|
||||
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"),
|
||||
},
|
||||
"folders": [{"year": 2026, "media_id": FOLDER_ID, "title": "2026"}],
|
||||
"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 add_video(repo, aid, up_mid, pubdate):
|
||||
repo.upsert_from_fav(
|
||||
FavResource(
|
||||
aid=aid, bvid=f"BV{aid}", title=f"视频{aid}", up_mid=up_mid,
|
||||
up_name=f"UP{up_mid}", pubdate=pubdate, duration=600,
|
||||
cover=None, attr=0, fav_time=None,
|
||||
),
|
||||
FOLDER_ID,
|
||||
was_added=True,
|
||||
)
|
||||
|
||||
|
||||
def add_candidate(repo, aid, up_mid=100, pubdate=NOW - 3600):
|
||||
return repo.upsert_candidate(
|
||||
aid=aid,
|
||||
bvid=f"BV{aid}",
|
||||
title=f"咻咻满 reaction {aid}",
|
||||
up_mid=up_mid,
|
||||
up_name=f"UP{up_mid}",
|
||||
pubdate=pubdate,
|
||||
cover="https://example/cover.jpg",
|
||||
score=3,
|
||||
channel="search:test#p1",
|
||||
now=NOW,
|
||||
)
|
||||
|
||||
|
||||
def setup(tmp, order, *, fail_deal=False):
|
||||
path = write_config(tmp)
|
||||
store = ConfigStore(path, env={})
|
||||
db = Database(Path(tmp) / "app.db", backup_dir=Path(tmp) / "backup")
|
||||
db.initialize()
|
||||
repo = Repository(db, clock=lambda: NOW)
|
||||
add_video(repo, 1, 100, 1_792_000_000)
|
||||
add_video(repo, 2, 200, 1_791_000_000)
|
||||
add_candidate(repo, 9, up_mid=100, pubdate=1_793_000_000)
|
||||
client = FakeWriteClient(order, fail_deal=fail_deal)
|
||||
service = FavoriteActionService(
|
||||
store, repo, FakeProvider(client), clock=lambda: NOW
|
||||
)
|
||||
return db, repo, client, service
|
||||
|
||||
|
||||
def test_favorite_inserts_and_positions():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db, repo, client, service = setup(tmp, [1, 2])
|
||||
try:
|
||||
result = service.favorite(9)
|
||||
assert result.ok is True
|
||||
assert result.position == 1
|
||||
assert result.already_in_folder is False
|
||||
assert client.dealt == [(9, FOLDER_ID)]
|
||||
assert client.sorts == ["1:2:9:2", "9:2:1:2"]
|
||||
assert client.order == [9, 1, 2]
|
||||
assert repo.get_candidate(9) is None
|
||||
video = repo.get_video(9)
|
||||
assert video["status"] == "active"
|
||||
assert video["folder_id"] == FOLDER_ID
|
||||
action = repo.get_fav_action("fav:9")
|
||||
assert action["state"] == "done"
|
||||
assert action["position"] == 1
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_favorite_already_in_folder_skips_writes():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db, repo, client, service = setup(tmp, [1, 9, 2])
|
||||
try:
|
||||
result = service.favorite(9)
|
||||
assert result.ok is True
|
||||
assert result.already_in_folder is True
|
||||
assert result.position == 2
|
||||
assert client.dealt == []
|
||||
assert client.sorts == []
|
||||
assert repo.get_candidate(9) is None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_favorite_failure_keeps_candidate_and_allows_retry():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db, repo, client, service = setup(tmp, [1, 2], fail_deal=True)
|
||||
try:
|
||||
result = service.favorite(9)
|
||||
assert result.ok is False
|
||||
assert "收藏夹已满" in result.message
|
||||
assert repo.get_candidate(9) is not None
|
||||
action = repo.get_fav_action("fav:9")
|
||||
assert action["state"] == "failed"
|
||||
assert "收藏夹已满" in action["err"]
|
||||
|
||||
client.fail_deal = False
|
||||
retry = service.favorite(9)
|
||||
assert retry.ok is True
|
||||
assert retry.position == 1
|
||||
assert repo.get_candidate(9) is None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_ignore_and_blacklist():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db, repo, client, service = setup(tmp, [1, 2])
|
||||
try:
|
||||
add_candidate(repo, 11, up_mid=300)
|
||||
add_candidate(repo, 12, up_mid=300)
|
||||
ignored = service.ignore(11)
|
||||
assert ignored.ok is True
|
||||
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 repo.get_video(1)["status"] == "active"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def main():
|
||||
test_favorite_inserts_and_positions()
|
||||
test_favorite_already_in_folder_skips_writes()
|
||||
test_favorite_failure_keeps_candidate_and_allows_retry()
|
||||
test_ignore_and_blacklist()
|
||||
print("OK: 收藏、忽略、拉黑与失败重试测试全部通过")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -143,11 +143,19 @@ def test_initial_import_and_incremental_skip():
|
||||
clock = [1_800_000_000]
|
||||
db, repo, service = make_service(tmp, transport, clock)
|
||||
try:
|
||||
repo.upsert_candidate(
|
||||
aid=1101, bvid="BV1101", title="咻咻满 reaction",
|
||||
up_mid=100, up_name="UP", pubdate=1_799_000_000,
|
||||
cover=None, score=3, channel="search:test#p1",
|
||||
now=1_799_999_000,
|
||||
)
|
||||
summary = service.run()
|
||||
assert summary.ok
|
||||
assert summary.added == 4
|
||||
assert summary.missing == 1
|
||||
assert summary.refreshed == 3
|
||||
# 人工加入收藏夹后,同 aid 候选自动移除
|
||||
assert repo.get_candidate(1101) is None
|
||||
assert repo.seed_blacklist([999], now=0) == 0 # 已由 service 写入
|
||||
|
||||
assert repo.get_video(1101)["status"] == "active"
|
||||
|
||||
@@ -37,18 +37,22 @@ def test_fresh_and_idempotent():
|
||||
conn = sqlite3.connect(Path(tmp) / "app.db")
|
||||
try:
|
||||
applied = run_migrations(conn)
|
||||
assert applied == [1, 2]
|
||||
assert current_version(conn) == 2
|
||||
assert applied == [1, 2, 3]
|
||||
assert current_version(conn) == 3
|
||||
assert EXPECTED_TABLES <= table_names(conn)
|
||||
assert "schema_version" in table_names(conn)
|
||||
columns = {
|
||||
row[1] for row in conn.execute("PRAGMA table_info(folder_resource)")
|
||||
}
|
||||
assert "fav_time" in columns
|
||||
candidate_columns = {
|
||||
row[1] for row in conn.execute("PRAGMA table_info(candidate)")
|
||||
}
|
||||
assert {"duration", "play", "like", "favorite", "danmaku", "review"} <= candidate_columns
|
||||
# 再跑一次不应重复应用
|
||||
assert run_migrations(conn) == []
|
||||
row = conn.execute("SELECT COUNT(*) FROM schema_version").fetchone()
|
||||
assert row[0] == 2
|
||||
assert row[0] == 3
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -63,15 +67,15 @@ def test_backup_before_upgrade():
|
||||
conn.execute("INSERT INTO kv(k, v) VALUES ('hello', 'world')")
|
||||
conn.commit()
|
||||
|
||||
v3 = Migration(3, "add probe", ("CREATE TABLE probe (id INTEGER PRIMARY KEY)",))
|
||||
v4 = Migration(4, "add probe", ("CREATE TABLE probe (id INTEGER PRIMARY KEY)",))
|
||||
applied = run_migrations(
|
||||
conn,
|
||||
backup_dir=backup_dir,
|
||||
now=1_800_000_000,
|
||||
migrations=MIGRATIONS + (v3,),
|
||||
migrations=MIGRATIONS + (v4,),
|
||||
)
|
||||
assert applied == [3]
|
||||
backups = list(backup_dir.glob("app-pre-v2-*.db"))
|
||||
assert applied == [4]
|
||||
backups = list(backup_dir.glob("app-pre-v3-*.db"))
|
||||
assert len(backups) == 1
|
||||
check = sqlite3.connect(backups[0])
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""WBI 签名与搜索解析测试(不联网): uv run python tests/test_search.py"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import urllib.parse
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
from reaction_board.bilibili.client import BilibiliError, BilibiliProtocolError # noqa: E402
|
||||
from reaction_board.bilibili.search import ( # noqa: E402
|
||||
SearchClient,
|
||||
key_from_url,
|
||||
mixin_key,
|
||||
parse_search_payload,
|
||||
sign_params,
|
||||
)
|
||||
|
||||
IMG_URL = "https://i0.hdslb.com/bfs/wbi/7cd084941338484aae1ad9425b84077c.png"
|
||||
SUB_URL = "https://i0.hdslb.com/bfs/wbi/4932caff0ff746eab6f01bf08b70ac45.png"
|
||||
|
||||
NAV_PAYLOAD = {
|
||||
"code": 0, "message": "0", "ttl": 1,
|
||||
"data": {
|
||||
"isLogin": True,
|
||||
"wbi_img": {"img_url": IMG_URL, "sub_url": SUB_URL},
|
||||
},
|
||||
}
|
||||
|
||||
SEARCH_PAYLOAD = {
|
||||
"code": 0, "message": "0", "ttl": 1,
|
||||
"data": {
|
||||
"result": [
|
||||
{
|
||||
"type": "video",
|
||||
"id": 117530286889638,
|
||||
"bvid": "BV1xvxi6LEZD",
|
||||
"title": '<em class="keyword">咻咻满</em> reaction 初听',
|
||||
"author": "测试UP",
|
||||
"mid": 434442072,
|
||||
"pubdate": 1793370433,
|
||||
"duration": "105:5",
|
||||
"pic": "//i0.hdslb.com/bfs/archive/example.jpg",
|
||||
"play": 12345,
|
||||
"like": 678,
|
||||
"favorites": 90,
|
||||
"danmaku": 12,
|
||||
"review": 3,
|
||||
},
|
||||
{"type": "article", "id": 999, "title": "非视频"},
|
||||
],
|
||||
"numPages": 1,
|
||||
"numResults": 1,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, responses):
|
||||
self.responses = list(responses)
|
||||
self.urls = []
|
||||
|
||||
def get_json(self, url, *, auth=False):
|
||||
self.urls.append((url, auth))
|
||||
item = self.responses.pop(0)
|
||||
if isinstance(item, BaseException):
|
||||
raise item
|
||||
return item
|
||||
|
||||
|
||||
def test_wbi_helpers():
|
||||
assert key_from_url(IMG_URL) == "7cd084941338484aae1ad9425b84077c"
|
||||
assert key_from_url(None) == ""
|
||||
key = mixin_key(
|
||||
"7cd084941338484aae1ad9425b84077c",
|
||||
"4932caff0ff746eab6f01bf08b70ac45",
|
||||
)
|
||||
assert key == "ea1db124af3c7062474693fa704f4ff8"
|
||||
|
||||
signed = sign_params({"keyword": "咻咻满", "page": 1}, key, now=1_700_000_000)
|
||||
assert signed["wts"] == 1_700_000_000
|
||||
assert len(signed["w_rid"]) == 32
|
||||
changed = sign_params({"keyword": "咻咻满", "page": 2}, key, now=1_700_000_000)
|
||||
assert changed["w_rid"] != signed["w_rid"]
|
||||
|
||||
|
||||
def test_parse_search_payload():
|
||||
items = parse_search_payload(SEARCH_PAYLOAD)
|
||||
assert len(items) == 1
|
||||
item = items[0]
|
||||
assert item.aid == 117530286889638
|
||||
assert item.title == "咻咻满 reaction 初听"
|
||||
assert item.duration_seconds == 105 * 60 + 5
|
||||
assert item.cover.startswith("https://")
|
||||
assert item.play == 12345 and item.like == 678
|
||||
assert item.favorite == 90 and item.danmaku == 12 and item.review == 3
|
||||
|
||||
|
||||
def test_wbi_search_and_fallback():
|
||||
fake = FakeClient([NAV_PAYLOAD, SEARCH_PAYLOAD])
|
||||
client = SearchClient(lambda: fake, clock=lambda: 1_700_000_000)
|
||||
items = client.search("咻咻满 reaction", 1)
|
||||
assert len(items) == 1
|
||||
assert "/x/web-interface/nav" in fake.urls[0][0]
|
||||
assert "/wbi/search/type" in fake.urls[1][0]
|
||||
query = urllib.parse.parse_qs(urllib.parse.urlparse(fake.urls[1][0]).query)
|
||||
assert query["w_rid"][0] and query["wts"][0] == "1700000000"
|
||||
|
||||
# nav 失败时降级旧接口
|
||||
fake = FakeClient([BilibiliError("wbi unavailable"), SEARCH_PAYLOAD])
|
||||
client = SearchClient(lambda: fake, clock=lambda: 1_700_000_000)
|
||||
items = client.search("咻咻满 reaction", 2)
|
||||
assert len(items) == 1
|
||||
assert "/x/web-interface/search/type" in fake.urls[1][0]
|
||||
|
||||
|
||||
def test_missing_result_is_protocol_error():
|
||||
bad = {"code": 0, "message": "0", "data": {}}
|
||||
fake = FakeClient([NAV_PAYLOAD, bad, bad])
|
||||
client = SearchClient(lambda: fake, clock=lambda: 1_700_000_000)
|
||||
try:
|
||||
client.search("x", 1)
|
||||
except BilibiliProtocolError as exc:
|
||||
assert "result" in str(exc)
|
||||
else:
|
||||
raise AssertionError("缺少 result 应报协议错误")
|
||||
|
||||
|
||||
def main():
|
||||
test_wbi_helpers()
|
||||
test_parse_search_payload()
|
||||
test_wbi_search_and_fallback()
|
||||
test_missing_result_is_protocol_error()
|
||||
print("OK: WBI 签名、搜索解析与降级测试全部通过")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,156 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Telegram 消息构造与回调分发测试(不联网):
|
||||
uv run python tests/test_telegram.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
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
|
||||
TelegramClient,
|
||||
format_duration,
|
||||
)
|
||||
from reaction_board.services.favorites import ActionResult # noqa: E402
|
||||
from reaction_board.services.telegram_poller import TelegramPoller # noqa: E402
|
||||
|
||||
|
||||
class FakeTransport:
|
||||
def __init__(self, result=None):
|
||||
self.result = result
|
||||
self.calls = []
|
||||
|
||||
def post(self, url, data, timeout=30):
|
||||
self.calls.append({"url": url, "data": dict(data), "timeout": timeout})
|
||||
return {"ok": True, "result": self.result if self.result is not None else {"message_id": 1}}
|
||||
|
||||
|
||||
def candidate(aid, title="咻咻满 reaction", up_name="UP", duration=3725):
|
||||
return {
|
||||
"aid": aid,
|
||||
"title": title,
|
||||
"up_name": up_name,
|
||||
"duration": duration,
|
||||
"play": 12345,
|
||||
"like": 678,
|
||||
}
|
||||
|
||||
|
||||
def test_format_duration():
|
||||
assert format_duration(0) == "0:00"
|
||||
assert format_duration(65) == "1:05"
|
||||
assert format_duration(3725) == "1:02:05"
|
||||
|
||||
|
||||
def test_send_candidates_and_chunking():
|
||||
transport = FakeTransport()
|
||||
bot = TelegramClient("token", 42, transport=transport)
|
||||
assert bot.send_candidates([candidate(1), candidate(2)]) is True
|
||||
assert len(transport.calls) == 1
|
||||
call = transport.calls[0]
|
||||
assert call["url"].endswith("/sendMessage")
|
||||
assert call["data"]["chat_id"] == 42
|
||||
assert "咻咻满 reaction" in call["data"]["text"]
|
||||
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",
|
||||
]
|
||||
|
||||
transport = FakeTransport()
|
||||
bot = TelegramClient("token", 42, transport=transport)
|
||||
bot.send_candidates([candidate(i) for i in range(4)])
|
||||
assert len(transport.calls) == 2
|
||||
assert len(json.loads(transport.calls[1]["data"]["reply_markup"])["inline_keyboard"]) == 1
|
||||
|
||||
|
||||
def test_get_updates_and_answer():
|
||||
transport = FakeTransport(result=[])
|
||||
bot = TelegramClient("token", 42, transport=transport, poll_timeout=17)
|
||||
assert bot.get_updates(offset=99) == []
|
||||
data = transport.calls[0]["data"]
|
||||
assert data["offset"] == 99
|
||||
assert data["timeout"] == 17
|
||||
assert json.loads(data["allowed_updates"]) == ["callback_query"]
|
||||
|
||||
transport = FakeTransport(result=True)
|
||||
bot = TelegramClient("token", 42, transport=transport)
|
||||
bot.answer_callback("cb1", "已加入")
|
||||
assert transport.calls[0]["url"].endswith("/answerCallbackQuery")
|
||||
assert transport.calls[0]["data"]["callback_query_id"] == "cb1"
|
||||
|
||||
|
||||
class FakeBot:
|
||||
chat_id = 42
|
||||
|
||||
def __init__(self, updates):
|
||||
self.updates = updates
|
||||
self.answers = []
|
||||
|
||||
def get_updates(self, offset=None):
|
||||
return list(self.updates)
|
||||
|
||||
def answer_callback(self, callback_id, text=""):
|
||||
self.answers.append((callback_id, text))
|
||||
|
||||
|
||||
class FakeActions:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def favorite(self, aid):
|
||||
self.calls.append(("fav", aid))
|
||||
return ActionResult(True, aid, "favorite", "done", position=3, message="已加入收藏夹")
|
||||
|
||||
def ignore(self, aid):
|
||||
self.calls.append(("ign", aid))
|
||||
return ActionResult(True, aid, "ignore", "rejected", message="已忽略")
|
||||
|
||||
def blacklist(self, aid):
|
||||
self.calls.append(("blk", aid))
|
||||
return ActionResult(True, aid, "blacklist", "rejected", message="已拉黑")
|
||||
|
||||
|
||||
def update(update_id, data, *, chat_id=42, from_id=42):
|
||||
return {
|
||||
"update_id": update_id,
|
||||
"callback_query": {
|
||||
"id": f"cb{update_id}",
|
||||
"data": data,
|
||||
"from": {"id": from_id},
|
||||
"message": {"chat": {"id": chat_id}},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_poller_dispatch_and_authorization():
|
||||
bot = FakeBot([
|
||||
update(1, "fav:9"),
|
||||
update(2, "ign:10"),
|
||||
update(3, "blk:11"),
|
||||
update(4, "fav:12", chat_id=999),
|
||||
update(5, "bad"),
|
||||
])
|
||||
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 bot.answers[3] == ("cb4", "无权限")
|
||||
assert bot.answers[4] == ("cb5", "未知操作")
|
||||
|
||||
|
||||
def main():
|
||||
test_format_duration()
|
||||
test_send_candidates_and_chunking()
|
||||
test_get_updates_and_answer()
|
||||
test_poller_dispatch_and_authorization()
|
||||
print("OK: Telegram 消息、分片、长轮询与回调授权测试全部通过")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user