Files
live-hub-py/tests/test_weibo_client.py
T

747 lines
23 KiB
Python

"""微博微豆基础客户端协议测试。"""
from __future__ import annotations
from typing import Any
import pytest
import requests
from core.weibo import (
WeiboApiError,
WeiboAuthError,
WeiboBindError,
WeiboBindQrCode,
WeiboClient,
WeiboExchangeError,
WeiboExchangeUncertainError,
WeiboHomeSnapshot,
WeiboRechargeError,
extract_bind_raw_url,
normalize_weibo_cookie,
)
BIND_URL = (
"sinaweibo://wbdiversion?username=gh_test"
"&path=%2Fpages%2FgameAccountBind%2Findex%3FactId%3D16722"
"%26code%3Dabc%252Bdef%253D%26gameId%3Dcjm"
"%26livePlatId%3Dweiyouxi%26nonce%3Dabc123"
"%26sig%3D0123456789abcdef%26t%3D1794046086%26v%3D2.0"
)
class FakeResponse:
def __init__(
self,
payload,
url: str = "",
status_code: int = 200,
headers: dict[str, str] | None = None,
):
self.payload = payload
self.text = payload if isinstance(payload, str) else ""
self.url = url
self.status_code = status_code
self.headers = {key.lower(): value for key, value in (headers or {}).items()}
def raise_for_status(self):
if self.status_code >= 400:
raise requests.HTTPError(f"HTTP {self.status_code}")
def json(self):
if not isinstance(self.payload, dict):
raise TypeError("not json")
return self.payload
class FakeSession(requests.Session):
def __init__(self, responses):
super().__init__()
self.responses = list(responses)
self.calls = []
def request(self, method, url, **_kwargs):
kwargs = _kwargs
self.calls.append((method, url, kwargs))
result = self.responses.pop(0)
if isinstance(result, Exception):
raise result
response = result if isinstance(result, FakeResponse) else FakeResponse(result)
if not response.url:
response.url = url
return response
def test_normalize_cookie_accepts_prefix_and_multiline():
raw = "Cookie: SCF=a;\n SUB=b;SUBP=c\n"
assert normalize_weibo_cookie(raw) == "SCF=a; SUB=b; SUBP=c"
def test_extract_bind_raw_url_preserves_inner_encoding():
raw = extract_bind_raw_url(BIND_URL)
assert raw.startswith("actId=16722&code=abc%2Bdef%3D")
assert "faceUrl" not in raw
# 内层 code 的多重编码不能被再次解开
assert "abc+def=" not in raw
def test_extract_bind_raw_url_rejects_wrong_path():
with pytest.raises(WeiboBindError, match="绑定小程序路径"):
extract_bind_raw_url("sinaweibo://wbdiversion?path=%2Fwrong")
def test_fetch_home_returns_basic_snapshot():
session = FakeSession(
[
{
"code": 100000,
"msg": "success",
"data": {
"user_info": {"uid": "4014000001", "nickname": "测试微博"},
"game_role": {
"is_bound": False,
"role_name": "",
"can_rebind": True,
"rebind_remain_days": 0,
"bind_url": BIND_URL,
},
"weidou_balance": {"balance": 100},
"task_list": [
{
"button_type": "cashier",
"action_url": (
"https://vipclub.weibo.com/vmember/subscribe"
"?vuid=8455793145&source_itemid=20601&F=bowen_tjg"
),
}
],
},
}
]
)
client = WeiboClient("SCF=a; SUB=b", session=session)
snapshot = client.fetch_home("bowen_tjg")
assert snapshot.uid == "4014000001"
assert snapshot.nickname == "测试微博"
assert snapshot.is_bound is False
assert snapshot.can_rebind is True
assert snapshot.weidou_balance == 100
assert snapshot.activity == "bowen_tjg"
assert "source_itemid=20601" in snapshot.recharge_url
method, url, kwargs = session.calls[0]
assert method == "GET"
assert "F=bowen_tjg" in url
assert "Cookie" not in kwargs["headers"]
assert session.cookies.get("SUB", domain=".weibo.com") == "b"
def test_fetch_home_marks_auth_error():
session = FakeSession([{"code": 400002, "msg": "未登陆"}])
client = WeiboClient("SUB=expired", session=session)
with pytest.raises(WeiboAuthError, match="未登陆"):
client.fetch_home()
def test_fetch_home_keeps_trace_id_for_other_api_error():
session = FakeSession(
[{"code": 100003, "msg": "活动异常", "trace_id": "trace-001"}]
)
client = WeiboClient("SUB=test", session=session)
with pytest.raises(WeiboApiError, match="trace-001"):
client.fetch_home()
def test_generate_bind_scheme_posts_account_type_without_weibo_cookie():
session = FakeSession(
[
{
"iRet": 0,
"sMsg": "ok",
"jData": {
"urlScheme": "weixin://dl/business/?appid=wx-test&query=scheme_id%3D1"
},
"apiName": "GenMiniappScheme",
}
]
)
client = WeiboClient(session=session)
scheme = client.generate_bind_scheme(BIND_URL, "wx")
assert scheme.startswith("weixin://dl/business/")
method, url, kwargs = session.calls[0]
assert method == "POST"
assert "c=Sdk&a=GenMiniappScheme&actId=16722" in url
assert kwargs["json"] == {"accountType": "wx"}
assert "Cookie" not in kwargs["headers"]
def test_generate_bind_scheme_rejects_missing_url_scheme():
session = FakeSession([{"iRet": 0, "sMsg": "ok", "jData": {}}])
client = WeiboClient(session=session)
with pytest.raises(WeiboBindError, match="urlScheme"):
client.generate_bind_scheme(BIND_URL, "qq")
def test_generate_bind_qrcode_posts_channel_and_preserves_raw_query():
session = FakeSession(
[
{
"iRet": 0,
"sMsg": "ok",
"jData": {
"qrcode": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB",
"qrcodeToken": "token-1",
},
}
]
)
client = WeiboClient(session=session)
qrcode = client.generate_bind_qrcode(BIND_URL, "qq")
assert isinstance(qrcode, WeiboBindQrCode)
assert qrcode.image_src == (
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
)
assert qrcode.token == "token-1"
method, url, kwargs = session.calls[0]
assert method == "POST"
assert url == "https://livelinkbind.game.qq.com/api/qrcode/getMiniAppQrcode"
body = kwargs["json"]
raw_url = extract_bind_raw_url(BIND_URL)
assert body["queryString"].startswith(f"{raw_url}&sessionId=")
assert body["accountType"] == "qq"
assert kwargs["headers"]["Gap-Act-Id"] == "16722"
assert kwargs["headers"]["Gap-Plat-Id"] == "weiyouxi"
assert kwargs["headers"]["Gap-User-From"] == "pc"
assert "Cookie" not in kwargs["headers"]
def test_generate_bind_qrcode_supports_jpeg_and_server_rate_limit():
jpeg_session = FakeSession(
[
{
"iRet": 0,
"sMsg": "ok",
"jData": {"qrcode": "/9j/4AAQSkZJRgABAQ", "qrcodeToken": "token-2"},
}
]
)
qrcode = WeiboClient(session=jpeg_session).generate_bind_qrcode(BIND_URL, "wx")
assert qrcode.image_src == "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ"
rate_limited = FakeSession(
[{"iRet": -1140603, "sMsg": "刷新二维码频率过快,请间隔5秒后重试"}]
)
with pytest.raises(WeiboBindError, match="间隔5秒"):
WeiboClient(session=rate_limited).generate_bind_qrcode(BIND_URL, "qq")
def test_mall_exchange_and_point_queries():
session = FakeSession(
[
{
"code": 100000,
"data": {
"list": [{"id": 37, "goods_name": "会员1天卡", "price": 100}],
"weidou_balance": 100,
},
},
{
"code": 100000,
"data": {
"page": 1,
"pageSize": 20,
"totalCount": 0,
"list": [],
},
},
{
"code": 100000,
"data": {
"page_num": 1,
"page_size": 20,
"total": 1,
"list": [{"id": 1, "source_name": "V+助威", "point_num": 100}],
},
},
]
)
client = WeiboClient("SUB=valid", session=session)
mall = client.list_mall("bowen_tjg")
records = client.list_exchange_records("bowen_tjg")
points = client.list_point_history("bowen_tjg")
assert mall["weidou_balance"] == 100
assert records["totalCount"] == 0
assert points["total"] == 1
assert session.calls[0][2]["params"]["F"] == "bowen_tjg"
def test_create_recharge_order_full_manual_chain():
home = {
"code": 100000,
"data": {
"user_info": {"uid": "4014000001", "nickname": "测试微博"},
"game_role": {
"is_bound": True,
"role_name": "测试角色",
"can_rebind": False,
"rebind_remain_days": 7,
"bind_url": BIND_URL,
},
"weidou_balance": {"balance": 0},
"task_list": [
{
"button_type": "cashier",
"action_url": (
"https://vipclub.weibo.com/vmember/subscribe"
"?vuid=8455793145&source_itemid=20601&F=bowen_tjg"
),
}
],
},
}
userinfo = {
"code": 100000,
"data": {
"cashier_id": 205,
"tab_info": [
{
"cashier_id": "205",
"tab_id": "536",
"tab_type": "8",
"is_current": 1,
}
],
},
}
cashierinfo = {
"code": 100000,
"data": {
"tab_info": [
{
"cashier_id": "205",
"tab_id": "536",
"tab_type": "8",
"is_current": 1,
"menu": [
{
"commodity_id": "2060101",
"subitemid": "2060101",
"itemid": 20601,
"appkey": 2000,
"max_money": 1000,
"discount_info": {"desc": "1元=100微豆"},
}
],
}
]
},
}
privilege = {
"code": 100000,
"data": {
"privilege": {
"url": "https://vipclub.weibo.com/appreciate/description?bid=1000294191"
}
},
}
createorder = {
"code": 100000,
"msg": "下单成功",
"data": {
"url": "https://pay.sc.weibo.com/api/merchant/pay/cashier?sign=test",
"orderid": "26111000000000000001",
},
}
cashier_html = """
<input type="hidden" value="1220351218153064346" node-type="pay_id" name="pay_id">
<img node-type="QRCode" src="https://qr.weibo.cn/inf/gen?sign=test">
<a href="https://pay.sc.weibo.com/pay/pc/comb?pay_id=1220351218153064346">立即支付</a>
"""
alipay_form_html = """
<form action="https://mapi.alipay.com/gateway.do" method="GET">
<input name="out_trade_no" value="1020351218229058766">
<input name="total_fee" value="1.00">
</form>
"""
alipay_html = """
<input name="qrCode" type="hidden" value="https://qr.alipay.com/upx-test" id="J_qrCode" />
"""
session = FakeSession(
[
home,
{"code": 100000, "data": {}},
userinfo,
cashierinfo,
privilege,
createorder,
cashier_html,
alipay_form_html,
alipay_html,
]
)
client = WeiboClient("SCF=a; SUB=b", session=session)
order = client.create_recharge_order(amount_yuan=1, activity="bowen_tjg")
assert order.order_id == "26111000000000000001"
assert order.pay_id == "1220351218153064346"
assert order.single_reward == 100
assert order.alipay_qr_code == "https://qr.alipay.com/upx-test"
assert order.desktop_pay_url.startswith("https://pay.sc.weibo.com/")
create_call = next(
call for call in session.calls if call[1].endswith("/paycomponent/createorder")
)
assert create_call[2]["data"]["single_reward"] == "100"
assert '"bid":"1000294191"' in create_call[2]["data"]["ext"]
assert create_call[2]["params"] == create_call[2]["data"]
assert not any("/aj/pay/modular/prepare" in call[1] for call in session.calls)
def test_create_recharge_order_reports_payment_rejection():
client = WeiboClient("SCF=a; SUB=b", session=FakeSession([]))
def fake_context(
activity: str,
*,
home: WeiboHomeSnapshot | None = None,
) -> tuple[str, dict[str, str], dict[str, Any], dict[str, Any]]:
_ = (activity, home)
return (
"4014000001",
{"vuid": "vuid", "source_itemid": "20601", "F": "bowen_tjg"},
{"tab_type": "8", "cashier_id": "205"},
{
"tab_info": [
{
"is_current": 1,
"tab_type": "8",
"cashier_id": "205",
"menu": [
{
"subitemid": "2060101",
"itemid": 20601,
"appkey": 2000,
"commodity_id": "2060101",
"max_money": 1000,
}
],
}
]
},
)
def fake_bid(
vuid: str,
source_itemid: str,
tab_type: str,
activity: str,
) -> str:
_ = (vuid, source_itemid, tab_type, activity)
return "1000294191"
client._load_recharge_context = fake_context
client._recharge_bid = fake_bid
client._request_json = lambda *_args, **_kwargs: {
"code": 100000,
"data": {
"url": "https://pay.sc.weibo.com/api/merchant/pay/cashier?sign=test",
"orderid": "order-1",
},
}
def rejected_page(*_args, **_kwargs):
client._last_text_url = "https://pay.sc.weibo.com/sorry"
return "<title>404错误</title>"
client._request_text = rejected_page
with pytest.raises(WeiboRechargeError, match=r"支付页拒绝.*/sorry"):
client.create_recharge_order(amount_yuan=1, activity="bowen_tjg")
@pytest.mark.parametrize(
("location", "message"),
[
(
"https://pay.sc.weibo.com/sso/login.php?url=test",
"微博支付未登录",
),
(
"https://weibo.com/sorry?usernotexists",
"支付账号不存在",
),
],
)
def test_recharge_preflight_detects_payment_auth_redirect(location, message):
home = WeiboHomeSnapshot(
uid="4014000001",
nickname="测试微博",
is_bound=False,
role_name="",
can_rebind=True,
rebind_remain_days=0,
weidou_balance=100,
bind_url=BIND_URL,
recharge_url=(
"https://vipclub.weibo.com/vmember/subscribe"
"?vuid=8455793145&source_itemid=20601&F=bowen_tjg"
),
)
session = FakeSession(
[
{"code": 100000, "data": {}},
{
"code": 100000,
"data": {
"cashier_id": 205,
"tab_info": [
{
"cashier_id": "205",
"tab_id": "536",
"tab_type": "8",
"is_current": 1,
}
],
},
},
{
"code": 100000,
"data": {
"tab_info": [
{
"cashier_id": "205",
"tab_id": "536",
"tab_type": "8",
"is_current": 1,
"menu": [
{
"price": 6000,
"weibo_unified_params": {
"seller_id": 6329048632,
"modular_id": "7d5d81dfb1",
"default_cashier": "ali_cashier",
},
}
],
}
]
},
},
FakeResponse(
"",
status_code=302,
headers={"Location": location},
),
]
)
client = WeiboClient("SCF=a; SUB=b", session=session)
with pytest.raises(WeiboRechargeError, match=message):
client.check_payment_access("bowen_tjg", home=home)
def test_create_recharge_order_rejects_missing_dynamic_menu():
home = {
"code": 100000,
"data": {
"user_info": {"uid": "1", "nickname": "测试"},
"game_role": {
"is_bound": False,
"role_name": "",
"can_rebind": True,
"rebind_remain_days": 0,
"bind_url": BIND_URL,
},
"weidou_balance": {"balance": 0},
"task_list": [
{
"button_type": "cashier",
"action_url": (
"https://vipclub.weibo.com/vmember/subscribe"
"?vuid=8455793145&source_itemid=20601&F=bowen_tjg"
),
}
],
},
}
userinfo = {
"code": 100000,
"data": {
"cashier_id": 205,
"tab_info": [
{
"cashier_id": "205",
"tab_id": "536",
"tab_type": "8",
"is_current": 1,
}
],
},
}
cashierinfo = {
"code": 100000,
"data": {
"tab_info": [
{
"cashier_id": "205",
"tab_id": "536",
"tab_type": "8",
"is_current": 1,
"menu": [],
}
]
},
}
session = FakeSession([home, {"code": 100000, "data": {}}, userinfo, cashierinfo])
client = WeiboClient("SUB=valid", session=session)
with pytest.raises(WeiboRechargeError, match="自定义微豆充值档位"):
client.create_recharge_order(amount_yuan=1)
def test_get_payment_status():
session = FakeSession(
[
{
"code": "100000",
"msg": "操作成功",
"data": {
"pay_id": "pay-1",
"out_pay_id": "out-1",
"status": "PAY_STATUS_PENDING",
"sub_status": "PAY_SUB_STATUS_INITIAL",
},
}
]
)
client = WeiboClient("SUB=valid", session=session)
status = client.get_payment_status("pay-1")
assert status.status == "PAY_STATUS_PENDING"
assert session.calls[0][2]["data"]["pay_id"] == "pay-1"
def test_exchange_goods_executes_once_and_verifies_balance_and_stock():
before = {
"code": 100000,
"data": {
"weidou_balance": 200,
"list": [
{
"id": 37,
"goods_name": "微博会员1天卡",
"price": 100,
"remain_count": 10,
"is_sold_out": False,
}
],
},
}
after = {
"code": 100000,
"data": {
"weidou_balance": 100,
"list": [
{
"id": 37,
"goods_name": "微博会员1天卡",
"price": 100,
"remain_count": 9,
"is_sold_out": False,
}
],
},
}
session = FakeSession(
[
before,
{"code": 100000, "msg": "success", "data": None},
after,
]
)
client = WeiboClient("SUB=valid", session=session)
result = client.exchange_goods(
37,
expected_price=100,
activity="bowen_tjg",
)
assert result.goods_name == "微博会员1天卡"
assert result.balance_before == 200
assert result.balance_after == 100
assert result.remain_before == 10
assert result.remain_after == 9
assert result.verified is True
exchange_calls = [
call for call in session.calls if call[1].endswith("/game/exchangegoods")
]
assert len(exchange_calls) == 1
assert exchange_calls[0][2]["params"]["goods_id"] == 37
def test_exchange_goods_rejects_insufficient_balance_before_write():
session = FakeSession(
[
{
"code": 100000,
"data": {
"weidou_balance": 99,
"list": [
{
"id": 37,
"goods_name": "微博会员1天卡",
"price": 100,
"remain_count": 10,
"is_sold_out": False,
}
],
},
}
]
)
client = WeiboClient("SUB=valid", session=session)
with pytest.raises(WeiboExchangeError, match="余额不足"):
client.exchange_goods(37, expected_price=100)
assert not any(call[1].endswith("/game/exchangegoods") for call in session.calls)
def test_exchange_goods_marks_transport_failure_as_uncertain():
session = FakeSession(
[
{
"code": 100000,
"data": {
"weidou_balance": 200,
"list": [
{
"id": 37,
"goods_name": "微博会员1天卡",
"price": 100,
"remain_count": 10,
"is_sold_out": False,
}
],
},
},
requests.ConnectionError("connection reset"),
]
)
client = WeiboClient("SUB=valid", session=session)
with pytest.raises(WeiboExchangeUncertainError, match="结果未知"):
client.exchange_goods(37, expected_price=100)