use serde::{Deserialize, Serialize};
#[cfg(test)]
const BILI_TICKET_ENDPOINT: &str =
"https://api.bilibili.com/bapis/bilibili.api.ticket.v1.Ticket/GenWebTicket";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TicketData {
pub ticket: String,
pub created_at: i64,
pub ttl: i32,
pub context: serde_json::Value,
pub nav: NavData,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NavData {
pub img: String,
pub sub: String,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ApiEnvelope;
use crate::probe::contract::HttpMethod;
use crate::probe::endpoint_contract::EndpointContract;
use crate::sign::bili_ticket::{hexsign, ticket_request_params};
use crate::{BpiClient, BpiError};
fn local_bili_ticket_probe_body(profile: &str) -> Option<serde_json::Value> {
let path = format!("target/bpi-probe-runs/misc/sign/bili-ticket/{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 hmac_sha256_returns_hex_digest() -> Result<(), BpiError> {
let result = hexsign("XgwSnGZ1p", 1_234_567_890)?;
assert_eq!(result.len(), 64);
assert!(result.chars().all(|c| c.is_ascii_hexdigit()));
Ok(())
}
#[test]
fn bili_ticket_contract_matches_endpoint_request() -> Result<(), BpiError> {
let contract = EndpointContract::from_slice(include_bytes!(
"../../../tests/contracts/misc/sign/bili-ticket/contract.json"
))?;
assert_eq!(contract.name, "misc.bili_ticket");
assert_eq!(contract.request.method, HttpMethod::Post);
assert_eq!(contract.request.url.as_str(), BILI_TICKET_ENDPOINT);
assert_eq!(
contract.request.query.get("key_id").map(String::as_str),
Some("ec02")
);
assert_eq!(
contract
.request
.query
.get("context[ts]")
.map(String::as_str),
Some("${unix_ts}")
);
assert_eq!(
contract.request.query.get("hexsign").map(String::as_str),
Some("${bili_ticket_hexsign}")
);
assert_eq!(
contract.request.query.get("csrf").map(String::as_str),
Some("${csrf}")
);
assert_eq!(contract.cases.len(), 3);
Ok(())
}
#[test]
fn bili_ticket_contract_covers_guest_and_authenticated_profiles() -> Result<(), BpiError> {
let contract = EndpointContract::from_slice(include_bytes!(
"../../../tests/contracts/misc/sign/bili-ticket/contract.json"
))?;
let anonymous = &contract.cases[0];
assert_eq!(anonymous.profile.as_deref(), Some("anonymous"));
assert!(!anonymous.auth.requires_cookie());
assert_eq!(anonymous.response.api_code, Some(0));
for case in &contract.cases[1..] {
assert!(matches!(case.name.as_str(), "normal" | "vip"));
assert!(case.auth.requires_cookie());
assert!(case.auth.requires_csrf());
assert_eq!(case.response.rust_model.as_deref(), Some("TicketData"));
}
Ok(())
}
#[test]
fn bili_ticket_response_fixture_parses_declared_model() -> Result<(), BpiError> {
let data = ApiEnvelope::<TicketData>::from_slice(include_bytes!(
"../../../tests/contracts/misc/sign/bili-ticket/responses/success.json"
))?
.into_payload()?;
assert_eq!(data.ttl, 259_200);
assert_eq!(data.ticket.split('.').count(), 3);
assert!(data.nav.img.starts_with("https://"));
assert!(data.nav.sub.starts_with("https://"));
Ok(())
}
#[test]
fn bili_ticket_model_matches_local_probe_outputs_when_available() -> Result<(), BpiError> {
for profile in ["anonymous", "normal", "vip"] {
let Some(body) = local_bili_ticket_probe_body(profile) else {
continue;
};
let data = serde_json::from_value::<ApiEnvelope<TicketData>>(body)?.into_payload()?;
assert_eq!(data.ttl, 259_200);
assert_eq!(data.ticket.split('.').count(), 3);
assert!(!data.nav.img.trim().is_empty());
assert!(!data.nav.sub.trim().is_empty());
}
Ok(())
}
#[test]
fn bili_ticket_client_can_build_guest_request_params() -> Result<(), BpiError> {
let client = BpiClient::new()?;
assert!(client.get_account().is_none());
assert!(client.csrf().is_err());
let params = ticket_request_params(1_234_567_890, "")?;
assert_eq!(params[3], ("csrf".to_string(), "".to_string()));
Ok(())
}
#[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
#[tokio::test]
async fn test_generate_bili_ticket() -> Result<(), BpiError> {
let Some(bpi) = live_client_or_skip()? else {
return Ok(());
};
match bpi.misc().bili_ticket().await {
Ok(data) => {
tracing::info!("Ticket: {}", data.ticket);
tracing::info!("创建时间: {}", data.created_at);
tracing::info!(
"有效时长: {} 秒 ({:.1} 天)",
data.ttl,
(data.ttl as f64) / 86400.0
);
tracing::info!("WBI img: {}", data.nav.img);
tracing::info!("WBI sub: {}", data.nav.sub);
assert!(data.ticket.contains('.'));
assert!(data.ttl > 250000); }
Err(err) => {
panic!("生成 bili_ticket 失败: {}", err);
}
}
Ok(())
}
#[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
#[tokio::test]
async fn test_get_bili_ticket_string() -> Result<(), BpiError> {
let Some(bpi) = live_client_or_skip()? else {
return Ok(());
};
match bpi.misc().bili_ticket_string().await {
Ok(ticket) => {
tracing::info!("获取到的 bili_ticket: {}", ticket);
assert!(!ticket.is_empty());
assert!(ticket.contains('.'));
let parts: Vec<&str> = ticket.split('.').collect();
assert_eq!(parts.len(), 3);
}
Err(err) => {
panic!("获取 bili_ticket 字符串失败: {}", err);
}
}
Ok(())
}
#[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
#[tokio::test]
async fn test_with_csrf() -> Result<(), BpiError> {
let Some(bpi) = live_client_or_skip()? else {
return Ok(());
};
match bpi.misc().bili_ticket().await {
Ok(data) => {
tracing::info!("带 CSRF 的 bili_ticket 生成成功: {}", data.ticket);
}
Err(err) => {
tracing::info!("带 CSRF 测试失败(预期可能失败): {}", err);
}
}
Ok(())
}
fn live_client_or_skip() -> Result<Option<BpiClient>, BpiError> {
if std::env::var("BPI_LIVE_TEST").ok().as_deref() != Some("1") {
return Ok(None);
}
let Some(cookie) = std::env::var("BPI_COOKIE")
.ok()
.filter(|value| !value.is_empty())
else {
return Ok(None);
};
BpiClient::builder().cookie(cookie).build().map(Some)
}
}