Backfill same-day snapshots for newly added or restored videos, preserve successful fan snapshots on resume, persist incomplete state before forced re-runs, and split collection completion from backup completion so failed backups can retry without API calls. Reject missing data payloads instead of treating them as empty folders, guard against mass-unfav on suspicious empty remote folders, rebuild Bilibili clients when credentials change, recompute primary folder ownership from active relations with per-folder fav_time, preserve original titles for dropped invalid videos, poll config every 60 seconds, and fix favsort verify interval and mixed resource types.
426 lines
13 KiB
Python
426 lines
13 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,
|
|
NAV_PAYLOAD,
|
|
RELATION_PAYLOAD,
|
|
VALID_INFO,
|
|
VIEW_PAYLOAD,
|
|
VIEW_UNAVAILABLE,
|
|
info_for,
|
|
view_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 test_view_seven_metrics():
|
|
transport = QueueTransport([http_json(VIEW_PAYLOAD)])
|
|
client, _ = make_client(transport)
|
|
stat = client.view(117530286889638)
|
|
assert stat.aid == 117530286889638
|
|
assert stat.bvid == "BV1xvxi6LEZD"
|
|
assert stat.metrics() == {
|
|
"view": 819, "like": 44, "favorite": 43, "coin": 9,
|
|
"share": 1, "reply": 16, "danmaku": 7,
|
|
}
|
|
assert "aid=117530286889638" in transport.calls[0]["url"]
|
|
|
|
|
|
def test_view_unavailable_and_malformed():
|
|
transport = QueueTransport([
|
|
http_json(VIEW_UNAVAILABLE), http_json(VIEW_UNAVAILABLE),
|
|
])
|
|
client, _ = make_client(transport)
|
|
try:
|
|
client.view(1)
|
|
except BilibiliAPIError as exc:
|
|
assert exc.code == 62012
|
|
else:
|
|
raise AssertionError("不可访问视频应抛业务错误")
|
|
|
|
payload = view_for(1)
|
|
payload["data"]["stat"].pop("coin")
|
|
bad = http_json(payload)
|
|
transport = QueueTransport([bad, bad])
|
|
client, _ = make_client(transport)
|
|
try:
|
|
client.view(1)
|
|
except BilibiliProtocolError as exc:
|
|
assert "coin" in str(exc)
|
|
else:
|
|
raise AssertionError("缺少 stat 字段应报协议错误")
|
|
|
|
|
|
def test_relation_stat():
|
|
transport = QueueTransport([http_json(RELATION_PAYLOAD)])
|
|
client, _ = make_client(transport)
|
|
fan = client.relation_stat(37754047)
|
|
assert fan.mid == 37754047
|
|
assert fan.follower == 2767268
|
|
assert fan.following == 95
|
|
assert "vmid=37754047" in transport.calls[0]["url"]
|
|
|
|
|
|
def test_check_login():
|
|
transport = QueueTransport([http_json(NAV_PAYLOAD)])
|
|
client, _ = make_client(transport)
|
|
state = client.check_login()
|
|
assert state.is_login is True
|
|
assert state.mid == 28802351
|
|
assert state.uname == "冷萃编译器"
|
|
assert "SESSDATA=secret" in transport.calls[0]["headers"]["cookie"]
|
|
|
|
transport = QueueTransport([http_json({
|
|
"code": 0, "message": "0", "ttl": 1,
|
|
"data": {"isLogin": False, "mid": 0, "uname": ""},
|
|
})])
|
|
client, _ = make_client(transport)
|
|
state = client.check_login()
|
|
assert state.is_login is False and state.mid == 0
|
|
|
|
transport = QueueTransport([http_json({"code": -101, "message": "账号未登录", "data": None})])
|
|
client, _ = make_client(transport)
|
|
state = client.check_login()
|
|
assert state.is_login is False
|
|
assert state.code == -101
|
|
assert "账号未登录" in state.message
|
|
|
|
|
|
def test_check_login_without_cookie_makes_no_request():
|
|
transport = QueueTransport([])
|
|
client, _ = make_client(transport, cookie="")
|
|
state = client.check_login()
|
|
assert state.is_login is False
|
|
assert state.message == "未配置 cookie"
|
|
assert transport.calls == []
|
|
|
|
|
|
def test_missing_data_is_protocol_error():
|
|
for payload in (
|
|
{"code": 0, "message": "OK"},
|
|
{"code": 0, "message": "OK", "data": None},
|
|
):
|
|
transport = QueueTransport([http_json(payload)])
|
|
client, _ = make_client(transport)
|
|
try:
|
|
client.fav_ids(1)
|
|
except BilibiliProtocolError as exc:
|
|
assert "data" in str(exc)
|
|
else:
|
|
raise AssertionError(f"ids 缺少 data 应报协议错误: {payload}")
|
|
|
|
transport = QueueTransport([http_json(payload)])
|
|
client, _ = make_client(transport)
|
|
try:
|
|
client.fav_infos(1, [123])
|
|
except BilibiliProtocolError as exc:
|
|
assert "data" in str(exc)
|
|
else:
|
|
raise AssertionError(f"infos 缺少 data 应报协议错误: {payload}")
|
|
|
|
|
|
def test_empty_data_list_is_valid():
|
|
transport = QueueTransport([http_json({"code": 0, "message": "OK", "data": []})])
|
|
client, _ = make_client(transport)
|
|
assert client.fav_ids(4413147139) == []
|
|
|
|
transport = QueueTransport([http_json({"code": 0, "message": "OK", "data": []})])
|
|
client, _ = make_client(transport)
|
|
result = client.fav_infos(1, [123, 456])
|
|
assert result.items == {}
|
|
assert result.missing == (123, 456)
|
|
|
|
|
|
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()
|
|
test_view_seven_metrics()
|
|
test_view_unavailable_and_malformed()
|
|
test_relation_stat()
|
|
test_check_login()
|
|
test_check_login_without_cookie_makes_no_request()
|
|
test_missing_data_is_protocol_error()
|
|
test_empty_data_list_is_valid()
|
|
print("OK: B 站只读客户端契约与重试测试全部通过")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|