Files
re-xxm/tests/test_folder_sync.py
T

374 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""收藏夹同步集成测试(用真实接口形状的 transport Mock,不联网):
uv run python tests/test_folder_sync.py
"""
import json
import os
import sys
import tempfile
import urllib.parse
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.stdout.reconfigure(encoding="utf-8")
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from fixtures import info_for # noqa: E402
from reaction_board.bilibili.client import ( # noqa: E402
BilibiliClient,
HTTPResponse,
Pacer,
TransportError,
)
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.folder_sync import FolderSyncService # noqa: E402
F1 = 4451053851
F2 = 3700023351
def http_json(payload):
return HTTPResponse(
200, {"content-type": "application/json"},
json.dumps(payload, ensure_ascii=False).encode("utf-8"),
)
class FakeFavTransport:
def __init__(self, folders, infos, *, fail_media=None, omit_ids_data=None):
self.folders = {k: list(v) for k, v in folders.items()}
self.infos = dict(infos)
self.fail_media = fail_media
self.omit_ids_data = omit_ids_data
self.ids_calls = []
self.info_calls = []
self.relation_calls = []
def request(self, method, url, headers, data=None, timeout=30):
query = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)
if "/x/relation/stat" in url:
mid = int(query["vmid"][0])
self.relation_calls.append(mid)
return http_json({
"code": 0, "message": "0", "ttl": 1,
"data": {"mid": mid, "following": 5, "follower": 1000 + mid},
})
if "/x/v3/fav/resource/ids" in url:
media_id = int(query["media_id"][0])
self.ids_calls.append(media_id)
if self.fail_media == media_id:
raise TransportError("ids 网络错误")
if self.omit_ids_data == media_id:
return http_json({"code": 0, "message": "OK", "ttl": 1})
data_rows = [
{"id": aid, "type": 2, "bv_id": f"BV{aid}", "bvid": f"BV{aid}"}
for aid in self.folders.get(media_id, [])
]
return http_json({"code": 0, "message": "0", "ttl": 1, "data": data_rows})
if "/x/v3/fav/resource/infos" in url:
media_id = int(query["folder_id"][0])
resources = query["resources"][0]
aids = [int(part.split(":")[0]) for part in resources.split(",")]
self.info_calls.append((media_id, aids))
rows = [self.infos[aid] for aid in aids if aid in self.infos]
return http_json({"code": 0, "message": "0", "ttl": 1, "data": rows})
if "/x/v3/fav/folder/created/list-all" in url:
return http_json({
"code": 0, "message": "0", "ttl": 1,
"data": {"count": 0, "list": []},
})
raise AssertionError(f"未预期的 URL: {url}")
def write_config(tmp, folders):
data = {
"bilibili": {
"read": {"mid": 28802351, "csrf": "csrf-secret", "cookie": "SESSDATA=secret; bili_jct=csrf-secret; buvid3=device"},
"write": {"mid": 2, "csrf": "write-csrf", "cookie": "write-cookie"},
},
"storage": {
"database_path": str(Path(tmp) / "app.db"),
"backup_dir": str(Path(tmp) / "backup"),
},
"folders": folders,
"up_blacklist_seed": [999],
"schedule": {
"discover_minutes": 60,
"folder_sync_minutes": 60,
"full_folder_refresh_hours": 6,
"snapshot_at": "00:01",
"candidate_ttl_days": 7,
},
}
path = Path(tmp) / "config.json"
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
return path
def make_service(tmp, transport, clock, *, capacity_warn=950):
folders = [
{"year": 2025, "media_id": F1, "title": "2025"},
{"year": 2026, "media_id": F2, "title": "2026"},
]
path = write_config(tmp, folders)
store = ConfigStore(path, env={}, require_auth=True)
db = Database(Path(tmp) / "app.db", backup_dir=Path(tmp) / "backup")
db.initialize()
repo = Repository(db, clock=lambda: clock[0])
client = BilibiliClient(
cookie="SESSDATA=secret; bvid3=device",
csrf="csrf",
mid=28802351,
transport=transport,
pacer=Pacer(0, 0),
retries=1,
)
service = FolderSyncService(
store, repo, client, clock=lambda: clock[0], capacity_warn=capacity_warn
)
return db, repo, service
def valid(aid, *, up_mid=100, title=None):
return info_for(aid, title=title or f"视频{aid}", up_mid=up_mid, up_name="UP")
def test_initial_import_and_incremental_skip():
with tempfile.TemporaryDirectory() as tmp:
transport = FakeFavTransport(
{F1: [1101, 1102, 1103, 1104], F2: []},
{
1101: valid(1101),
1102: valid(1102, up_mid=999), # 黑名单 UP 也必须正常入库
1103: valid(1103, title="已失效", up_mid=100),
},
)
# 1103 标记为 attr=9;1104 静默缺失
transport.infos[1103]["attr"] = 9
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"
assert repo.get_video(1102)["status"] == "active" # 黑名单不影响收藏夹导入
assert repo.get_video(1103)["status"] == "removed"
assert repo.get_video(1104)["status"] == "removed"
assert repo.get_video(1104)["bvid"] == "BV1104"
assert repo.folder_active_aids(F1) == {1101, 1102, 1103, 1104}
assert repo.is_blacklisted(999)
# 新入库视频记录入库时粉丝数;失效视频不再采集
assert summary.followers_updated == 2
assert summary.followers_failed == 0
assert repo.get_video(1101)["up_follower"] == 1100
assert repo.get_video(1101)["up_follower_backfilled"] == 0
assert repo.get_video(1102)["up_follower"] == 1999
assert sorted(transport.relation_calls) == [100, 999]
folder = repo.get_folder(F1)
assert folder.media_count == 4
assert folder.last_full_at == 1_800_000_000
# 6 小时内再次运行:没有新增,不请求 infos
clock[0] += 60
info_before = len(transport.info_calls)
relation_before = len(transport.relation_calls)
summary2 = service.run()
assert summary2.added == 0
assert summary2.refreshed == 0
assert len(transport.info_calls) == info_before
assert len(transport.relation_calls) == relation_before
assert summary2.folders[0].full_refresh is False
finally:
db.close()
def test_diff_add_and_remove():
with tempfile.TemporaryDirectory() as tmp:
transport = FakeFavTransport(
{F1: [1101, 1102], F2: []},
{1101: valid(1101), 1102: valid(1102)},
)
clock = [1_800_000_000]
db, repo, service = make_service(tmp, transport, clock)
try:
service.run()
assert repo.get_video(1101)["status"] == "active"
assert repo.get_video(1102)["status"] == "active"
transport.folders[F1] = [1102, 1103]
transport.infos[1103] = valid(1103)
clock[0] += 3600
summary = service.run()
assert summary.added == 1
assert summary.removed == 1
assert summary.refreshed == 1
assert repo.get_video(1101)["status"] == "unfaved"
assert repo.get_video(1102)["status"] == "active"
assert repo.get_video(1103)["status"] == "active"
assert repo.folder_active_aids(F1) == {1102, 1103}
assert repo.get_folder(F1).media_count == 2
finally:
db.close()
def test_full_refresh_marks_invalid_and_missing():
with tempfile.TemporaryDirectory() as tmp:
transport = FakeFavTransport(
{F1: [1101, 1102], F2: []},
{1101: valid(1101), 1102: valid(1102)},
)
clock = [1_800_000_000]
db, repo, service = make_service(tmp, transport, clock)
try:
service.run()
assert repo.get_video(1101)["status"] == "active"
assert repo.get_video(1102)["status"] == "active"
# 6 小时后:1101 失效,1102 静默缺失
clock[0] += 6 * 3600 + 1
transport.infos[1101]["attr"] = 9
del transport.infos[1102]
summary = service.run()
assert summary.folders[0].full_refresh is True
assert summary.missing == 1
assert repo.get_video(1101)["status"] == "removed"
assert repo.get_video(1102)["status"] == "removed"
assert repo.get_folder(F1).last_full_at == clock[0]
# 全量刷新请求了夹内全部 aid
assert set(transport.info_calls[-1][1]) == {1101, 1102}
finally:
db.close()
def test_capacity_warning_does_not_move_folder():
with tempfile.TemporaryDirectory() as tmp:
transport = FakeFavTransport(
{F1: [1, 2, 3], F2: []},
{i: valid(i) for i in (1, 2, 3)},
)
clock = [1_800_000_000]
db, repo, service = make_service(tmp, transport, clock, capacity_warn=3)
try:
summary = service.run()
assert summary.folders[0].warning
assert "预警线 3" in summary.folders[0].warning
assert "不会自动换夹" in summary.folders[0].warning
# 视频仍在原夹,不被移动
assert all(repo.get_video(i)["folder_id"] == F1 for i in (1, 2, 3))
finally:
db.close()
def test_folder_error_does_not_block_other_folders():
with tempfile.TemporaryDirectory() as tmp:
transport = FakeFavTransport(
{F1: [1101], F2: [2201]},
{1101: valid(1101), 2201: valid(2201)},
fail_media=F1,
)
clock = [1_800_000_000]
db, repo, service = make_service(tmp, transport, clock)
try:
summary = service.run()
assert summary.ok is False
assert len(summary.errors) == 1 and str(F1) in summary.errors[0]
assert summary.folders[0].error
assert summary.folders[1].error is None
assert repo.get_video(2201)["status"] == "active"
finally:
db.close()
def test_blacklist_seed_only_once():
with tempfile.TemporaryDirectory() as tmp:
transport = FakeFavTransport({F1: [1101], F2: []}, {1101: valid(1101)})
clock = [1_800_000_000]
db, repo, service = make_service(tmp, transport, clock)
try:
first = service.run()
assert first.blacklist_seeded == 1
clock[0] += 60
second = service.run()
assert second.blacklist_seeded == 0
assert len(repo.blacklist()) == 1
finally:
db.close()
def test_empty_remote_requires_explicit_opt_in():
with tempfile.TemporaryDirectory() as tmp:
transport = FakeFavTransport(
{F1: [1101, 1102], F2: []},
{1101: valid(1101), 1102: valid(1102)},
)
clock = [1_800_000_000]
db, repo, service = make_service(tmp, transport, clock)
try:
service.run()
transport.folders[F1] = []
clock[0] += 3600
summary = service.run()
assert summary.ok is False
assert "疑似接口异常" in summary.folders[0].error
assert repo.get_video(1101)["status"] == "active"
assert repo.folder_active_aids(F1) == {1101, 1102}
service.allow_empty_folders = True
summary = service.run()
assert summary.ok is True
assert repo.get_video(1101)["status"] == "unfaved"
assert repo.folder_active_aids(F1) == set()
finally:
db.close()
def test_missing_ids_data_aborts_folder():
with tempfile.TemporaryDirectory() as tmp:
transport = FakeFavTransport(
{F1: [1101], F2: []},
{1101: valid(1101)},
omit_ids_data=F1,
)
clock = [1_800_000_000]
db, repo, service = make_service(tmp, transport, clock)
try:
summary = service.run()
assert summary.ok is False
assert "data" in summary.folders[0].error
assert repo.get_video(1101) is None
finally:
db.close()
def main():
test_initial_import_and_incremental_skip()
test_diff_add_and_remove()
test_full_refresh_marks_invalid_and_missing()
test_capacity_warning_does_not_move_folder()
test_folder_error_does_not_block_other_folders()
test_blacklist_seed_only_once()
test_empty_remote_requires_explicit_opt_in()
test_missing_ids_data_aborts_folder()
print("OK: 收藏夹初始导入、差集同步、失效识别与容量预警测试全部通过")
if __name__ == "__main__":
main()