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.
296 lines
9.0 KiB
Python
296 lines
9.0 KiB
Python
# -*- 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()
|