Files
re-xxm/tests/test_bilibili_client.py
T

549 lines
18 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"]
assert call["headers"]["origin"] == "https://space.bilibili.com"
assert call["headers"]["priority"] == "u=1, i"
assert "Chrome/155.0.0.0" in call["headers"]["user-agent"]
assert "fid=3700023351" in call["headers"]["referer"]
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_parses_page_count():
payload = {"code": 0, "message": "0", "ttl": 1, "data": [VALID_INFO, INVALID_INFO]}
client, _ = make_client(QueueTransport([http_json(payload)]))
result = client.fav_infos(
3700023351, [VALID_INFO["id"], INVALID_INFO["id"]]
)
assert result.items[VALID_INFO["id"]].pages == 2
assert result.items[INVALID_INFO["id"]].pages == 1
# page 字段缺失或非法时保持 None,不把 0 当分 P 数
broken = json.loads(json.dumps(VALID_INFO))
broken["id"] = 999
broken["page"] = 0
client, _ = make_client(QueueTransport([http_json({
"code": 0, "message": "0", "ttl": 1, "data": [broken],
})]))
assert client.fav_infos(3700023351, [999]).items[999].pages is None
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_view_detail_by_aid_and_bvid():
transport = QueueTransport([http_json(VIEW_PAYLOAD)])
client, _ = make_client(transport)
detail = client.view_detail(aid=117530286889638)
assert detail.aid == 117530286889638
assert detail.bvid == "BV1xvxi6LEZD"
assert detail.up_mid == 434442072
assert detail.up_name == "_-抱抱-_"
assert detail.pubdate == 1793370433
assert detail.duration == 9504
assert detail.pages == 2
assert detail.cover and detail.cover.startswith("http")
assert detail.metrics()["view"] == 819
assert detail.up_face and detail.up_face.startswith("http")
assert "aid=117530286889638" in transport.calls[0]["url"]
transport = QueueTransport([http_json(VIEW_PAYLOAD)])
client, _ = make_client(transport)
detail = client.view_detail(bvid="BV1xvxi6LEZD")
assert detail.aid == 117530286889638
assert "bvid=BV1xvxi6LEZD" in transport.calls[0]["url"]
def test_view_detail_fallbacks_and_required_arg():
payload = view_for(1)
for key in ("owner", "duration", "pages", "videos", "pic"):
payload["data"].pop(key, None)
transport = QueueTransport([http_json(payload)])
client, _ = make_client(transport)
detail = client.view_detail(aid=1)
assert detail.up_mid is None and detail.up_name is None
assert detail.duration == 0
assert detail.pages is None
assert detail.cover is None
assert detail.up_face is None
client, _ = make_client(QueueTransport([]))
for kwargs in ({}, {"aid": 1, "bvid": "BV1xvxi6LEZD"}):
try:
client.view_detail(**kwargs)
except ValueError:
pass
else:
raise AssertionError("必须且只能提供 aid 或 bvid")
def test_view_detail_unavailable():
transport = QueueTransport([
http_json(VIEW_UNAVAILABLE), http_json(VIEW_UNAVAILABLE),
])
client, _ = make_client(transport)
try:
client.view_detail(bvid="BV1xvxi6LEZD")
except BilibiliAPIError as exc:
assert exc.code == 62012
else:
raise AssertionError("不可访问视频应抛业务错误")
def test_resolve_short_link():
redirect = HTTPResponse(
302,
{"location": "https://www.bilibili.com/video/BV1x8x166EzY"},
b"",
)
transport = QueueTransport([redirect])
client, _ = make_client(transport)
final = client.resolve_short_link("https://b23.tv/abc")
assert final == "https://www.bilibili.com/video/BV1x8x166EzY"
assert transport.calls[0]["method"] == "GET"
assert "b23.tv" in transport.calls[0]["url"]
# 多跳重定向,大小写不同的 Location 头
transport = QueueTransport([
HTTPResponse(301, {"Location": "https://b23.tv/next"}, b""),
HTTPResponse(302, {"location": "https://www.bilibili.com/video/BV1x8x166EzY"}, b""),
])
client, _ = make_client(transport)
assert client.resolve_short_link("https://b23.tv/start").endswith("BV1x8x166EzY")
# 非重定向直接返回最终 URL
transport = QueueTransport([HTTPResponse(200, {}, b"<html>")])
client, _ = make_client(transport)
assert client.resolve_short_link("https://b23.tv/x") == "https://b23.tv/x"
# HTTP 错误应抛出
transport = QueueTransport([HTTPResponse(404, {}, b"not found")])
client, _ = make_client(transport)
try:
client.resolve_short_link("https://b23.tv/x")
except BilibiliHTTPError as exc:
assert exc.status == 404
else:
raise AssertionError("短链 HTTP 404 应抛出")
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_view_detail_by_aid_and_bvid()
test_view_detail_fallbacks_and_required_arg()
test_view_detail_unavailable()
test_resolve_short_link()
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()