feat(p0): add favorite folder sync and sqlite data foundation

Implement P0 of the reaction dashboard: config loading with hot reload, SQLite migrations with backups, single-writer queue, read-only Bilibili fav client, folder import/diff sync, invalid-resource handling, blacklist seed, scheduler, and JSON redacted logging. Includes offline unit and transport-level contract tests; all existing and new tests pass.
This commit is contained in:
kairee committed 2026-10-31 18:08:33 +08:00
commit e2cdf997af
39 files changed
+8575

No files matched your search

+295
View File
@@ -0,0 +1,295 @@
# -*- coding: utf-8 -*-
"""B 站只读客户端测试(transport 边界 Mock,不联网):
uv run python tests/test_bilibili_client.py
"""
import json
import os
import sys
import urllib.parse
from dataclasses import dataclass, field
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 ( # noqa: E402
BilibiliAPIError,
BilibiliClient,
BilibiliHTTPError,
BilibiliProtocolError,
HTTPResponse,
Pacer,
TransportError,
)
from fixtures import ( # noqa: E402
FOLDERS_PAYLOAD,
IDS_PAYLOAD,
INVALID_INFO,
VALID_INFO,
info_for,
)
def http_json(payload, status=200):
return HTTPResponse(
status=status,
headers={"content-type": "application/json"},
body=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
)
@dataclass
class QueueTransport:
responses: list
calls: list = field(default_factory=list)
def request(self, method, url, headers, data=None, timeout=30):
self.calls.append({
"method": method, "url": url, "headers": dict(headers),
"data": data, "timeout": timeout,
})
item = self.responses.pop(0)
if isinstance(item, BaseException):
raise item
return item
@dataclass
class RouteTransport:
handler: object
calls: list = field(default_factory=list)
def request(self, method, url, headers, data=None, timeout=30):
self.calls.append({
"method": method, "url": url, "headers": dict(headers),
"data": data, "timeout": timeout,
})
return self.handler(url, headers)
def make_client(transport, *, cookie="SESSDATA=secret; buvid3=device", retries=3):
sleeps = []
client = BilibiliClient(
cookie=cookie,
csrf="csrf",
mid=28802351,
transport=transport,
pacer=Pacer(0, 0, sleep=sleeps.append),
sleeper=sleeps.append,
retries=retries,
backoff=1.0,
)
return client, sleeps
def test_fav_ids_parse_and_anonymous_header():
transport = QueueTransport([http_json(IDS_PAYLOAD)])
client, _ = make_client(transport)
ids = client.fav_ids(3700023351)
assert len(ids) == 4
assert ids[0].aid == 117530286889638
assert ids[0].type == 2
assert ids[0].bvid == "BV1xvxi6LEZD"
call = transport.calls[0]
assert "media_id=3700023351" in call["url"]
assert "SESSDATA" not in call["headers"]["cookie"]
assert "buvid3=device" in call["headers"]["cookie"]
def test_anonymous_failure_falls_back_to_full_cookie():
transport = QueueTransport([
http_json({"code": -101, "message": "账号未登录", "data": None}),
http_json(IDS_PAYLOAD),
])
client, _ = make_client(transport)
ids = client.fav_ids(3700023351)
assert len(ids) == 4
assert len(transport.calls) == 2
assert "SESSDATA" not in transport.calls[0]["headers"]["cookie"]
assert "SESSDATA=secret" in transport.calls[1]["headers"]["cookie"]
def test_business_error_is_not_retried():
error = {"code": -404, "message": "啥都木有", "data": None}
transport = QueueTransport([http_json(error), http_json(error)])
client, sleeps = make_client(transport)
try:
client.fav_ids(3700023351)
except BilibiliAPIError as exc:
assert exc.code == -404
assert "啥都木有" in str(exc)
else:
raise AssertionError("业务错误应抛出")
assert len(transport.calls) == 2 # 匿名 + 完整 Cookie 各一次,不重试
assert sleeps == []
def test_http_500_retries_with_backoff():
transport = QueueTransport([
http_json({"code": 0}, status=500),
http_json({"code": 0}, status=500),
http_json({"code": 0}, status=500),
])
client, sleeps = make_client(transport, retries=3)
try:
client.get_json("https://api.bilibili.com/x/test", auth=True)
except BilibiliHTTPError as exc:
assert exc.status == 500
else:
raise AssertionError("HTTP 500 应抛出")
assert len(transport.calls) == 3
assert sleeps == [1.0, 2.0]
def test_network_error_retry_then_success():
transport = QueueTransport([TransportError("reset"), http_json(IDS_PAYLOAD)])
client, sleeps = make_client(transport)
ids = client.fav_ids(3700023351)
assert len(ids) == 4
assert len(transport.calls) == 2
assert sleeps == [1.0]
def test_invalid_json_is_protocol_error():
bad = HTTPResponse(200, {}, b"not json")
transport = QueueTransport([bad, bad])
client, _ = make_client(transport)
try:
client.fav_ids(3700023351)
except BilibiliProtocolError as exc:
assert "JSON" in str(exc)
else:
raise AssertionError("非法 JSON 应抛出协议错误")
def test_infos_chunking_and_silent_missing():
aids = list(range(1000, 1450))
missing = 1003
def handler(url, headers):
query = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)
resources = query["resources"][0]
requested = [int(part.split(":")[0]) for part in resources.split(",")]
data = [
info_for(aid, title=f"视频{aid}", up_mid=100, up_name="UP")
for aid in requested if aid != missing
]
return http_json({"code": 0, "message": "0", "ttl": 1, "data": data})
transport = RouteTransport(handler)
client, _ = make_client(transport)
result = client.fav_infos(3700023351, aids, folder_mid=28802351, chunk_size=200)
assert len(transport.calls) == 3
sizes = []
for call in transport.calls:
query = urllib.parse.parse_qs(urllib.parse.urlparse(call["url"]).query)
sizes.append(len(query["resources"][0].split(",")))
assert sizes == [200, 200, 50]
assert result.requested == tuple(aids)
assert result.missing == (missing,)
assert len(result.items) == 449
assert result.items[1000].title == "视频1000"
assert result.items[1000].invalid is False
def test_infos_invalid_attr_and_empty():
transport = QueueTransport([
http_json({"code": 0, "message": "0", "ttl": 1, "data": [INVALID_INFO]})
])
client, _ = make_client(transport)
result = client.fav_infos(3700023351, [INVALID_INFO["id"]])
assert result.items[INVALID_INFO["id"]].invalid is True
assert result.missing == ()
# 空输入不发起请求
empty = client.fav_infos(3700023351, [])
assert empty.requested == () and empty.items == {}
assert len(transport.calls) == 1
def test_infos_unexpected_aid_is_protocol_error():
transport = QueueTransport([http_json({
"code": 0, "message": "0", "ttl": 1, "data": [VALID_INFO],
})])
client, _ = make_client(transport)
try:
client.fav_infos(3700023351, [1])
except BilibiliProtocolError as exc:
assert "未请求" in str(exc)
else:
raise AssertionError("返回未请求 aid 应报错")
def test_fav_folders_uses_full_cookie():
transport = QueueTransport([http_json(FOLDERS_PAYLOAD)])
client, _ = make_client(transport)
folders = client.fav_folders()
assert [(f.media_id, f.media_count) for f in folders] == [
(4451053851, 167), (3700023351, 664)
]
call = transport.calls[0]
assert "up_mid=28802351" in call["url"]
assert "SESSDATA=secret" in call["headers"]["cookie"]
def test_fav_infos_chunk_size_validation():
client, _ = make_client(QueueTransport([]))
for size in (0, 401):
try:
client.fav_infos(1, [1], chunk_size=size)
except ValueError:
pass
else:
raise AssertionError(f"chunk_size={size} 应失败")
def test_pacer_delays_after_first_request():
sleeps = []
class Rng:
def uniform(self, a, b):
assert (a, b) == (1.5, 2.5)
return 2.0
pacer = Pacer(1.5, 2.5, sleep=sleeps.append, rng=Rng())
assert pacer.wait() == 0.0
assert pacer.wait() == 2.0
assert pacer.wait() == 2.0
assert sleeps == [2.0, 2.0]
try:
Pacer(2.0, 1.0)
except ValueError:
pass
else:
raise AssertionError("非法间隔应失败")
def test_timeout_and_method_forwarded():
transport = QueueTransport([http_json(IDS_PAYLOAD)])
client, _ = make_client(transport)
client.fav_ids(1)
call = transport.calls[0]
assert call["method"] == "GET"
assert call["data"] is None
assert call["timeout"] == 30
def main():
test_fav_ids_parse_and_anonymous_header()
test_anonymous_failure_falls_back_to_full_cookie()
test_business_error_is_not_retried()
test_http_500_retries_with_backoff()
test_network_error_retry_then_success()
test_invalid_json_is_protocol_error()
test_infos_chunking_and_silent_missing()
test_infos_invalid_attr_and_empty()
test_infos_unexpected_aid_is_protocol_error()
test_fav_folders_uses_full_cookie()
test_fav_infos_chunk_size_validation()
test_pacer_delays_after_first_request()
test_timeout_and_method_forwarded()
print("OK: B 站只读客户端契约与重试测试全部通过")
if __name__ == "__main__":
main()