use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UserWallet {
pub mid: i64,
pub total_bp: f64,
pub default_bp: f64,
pub ios_bp: f64,
pub coupon_balance: f64,
pub available_bp: f64,
pub unavailable_bp: f64,
pub unavailable_reason: String,
pub tip: String,
pub need_show_class_balance: i64,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ApiEnvelope;
use crate::probe::contract::HttpMethod;
use crate::probe::endpoint_contract::EndpointContract;
use crate::wallet::params::WalletInfoParams;
use crate::{BpiClient, BpiError};
use tracing::info;
const WALLET_INFO_ENDPOINT: &str = "https://pay.bilibili.com/paywallet/wallet/getUserWallet";
fn local_wallet_probe_body(profile: &str) -> Option<serde_json::Value> {
let path = format!("target/bpi-probe-runs/wallet/read/info/{profile}.response.json");
let bytes = std::fs::read(path).ok()?;
let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
value
.get("response")
.and_then(|response| response.get("body"))
.cloned()
}
#[test]
fn wallet_info_contract_matches_endpoint_request() -> Result<(), BpiError> {
let contract = EndpointContract::from_slice(include_bytes!(
"../../tests/contracts/wallet/read/info/contract.json"
))?;
assert_eq!(contract.name, "wallet.info");
assert_eq!(contract.request.method, HttpMethod::Post);
assert_eq!(contract.request.url.as_str(), WALLET_INFO_ENDPOINT);
assert!(contract.request.query.is_empty());
assert_eq!(contract.cases.len(), 2);
let body = contract
.request
.body
.as_ref()
.ok_or_else(|| BpiError::unsupported_response("missing wallet contract body"))?;
assert_eq!(body["csrf"], "${csrf}");
assert_eq!(body["platformType"], 3);
assert_eq!(body["timestamp"], 1_700_000_000_000_i64);
assert_eq!(body["traceId"], 1_700_000_000_000_i64);
assert_eq!(body["version"], "1.0");
Ok(())
}
#[test]
fn wallet_info_contract_covers_authenticated_profiles() -> Result<(), BpiError> {
let contract = EndpointContract::from_slice(include_bytes!(
"../../tests/contracts/wallet/read/info/contract.json"
))?;
for case in &contract.cases {
assert!(matches!(case.name.as_str(), "normal" | "vip"));
assert!(case.auth.requires_cookie());
assert!(case.auth.requires_csrf());
assert_eq!(case.response.api_code, Some(0));
assert_eq!(case.response.rust_model.as_deref(), Some("UserWallet"));
assert_eq!(
case.response.fixture.as_deref(),
Some("responses/authenticated.success.json")
);
}
Ok(())
}
#[test]
fn wallet_response_fixture_parses_declared_model() -> Result<(), BpiError> {
let wallet = ApiEnvelope::<UserWallet>::from_slice(include_bytes!(
"../../tests/contracts/wallet/read/info/responses/authenticated.success.json"
))?
.into_payload()?;
assert_eq!(wallet.mid, 1_000_001);
assert_eq!(wallet.need_show_class_balance, 1);
Ok(())
}
#[test]
fn wallet_model_matches_local_probe_outputs_when_available() -> Result<(), BpiError> {
for profile in ["normal", "vip"] {
let Some(body) = local_wallet_probe_body(profile) else {
continue;
};
let wallet = serde_json::from_value::<ApiEnvelope<UserWallet>>(body)?.into_payload()?;
assert!(wallet.mid > 0);
assert_eq!(wallet.need_show_class_balance, 1);
}
Ok(())
}
#[test]
fn wallet_anonymous_local_probe_preserves_login_error_when_available() -> Result<(), BpiError> {
let Some(body) = local_wallet_probe_body("anonymous") else {
return Ok(());
};
let err = serde_json::from_value::<ApiEnvelope<serde_json::Value>>(body)?
.ensure_success()
.unwrap_err();
assert!(err.requires_login());
assert_eq!(err.code(), Some(800501007));
Ok(())
}
#[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
#[tokio::test]
async fn test_get_user_wallet() {
let bpi = BpiClient::new().expect("client should build");
let resp = bpi.wallet().info(WalletInfoParams::new()).await;
info!("响应: {:?}", resp);
assert!(resp.is_ok());
if let Ok(wallet) = resp {
info!("用户mid: {}", wallet.mid);
}
}
}