use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct LiveUser {
pub face: String,
pub link: String,
pub title: String,
pub uid: u64,
pub uname: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct LiveUsersData {
pub count: u64,
pub group: String,
pub items: Vec<LiveUser>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DynUpUser {
pub user_profile: UserProfile,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct UserProfile {
pub info: UserInfo,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct UserInfo {
pub uid: u64,
pub uname: String,
pub face: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DynUpUsersData {
pub button_statement: String,
pub items: Vec<DynUpUser>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dynamic::params::{DynamicLiveUsersParams, DynamicUpUsersParams};
use crate::probe::contract::HttpMethod;
use crate::probe::endpoint_contract::EndpointContract;
use crate::{ApiEnvelope, BpiClient, BpiError, BpiResult};
use std::collections::BTreeMap;
use tracing::info;
fn contract(endpoint: &str) -> BpiResult<EndpointContract> {
let bytes = match endpoint {
"live-users" => {
include_bytes!("../../tests/contracts/dynamic/content/live-users/contract.json")
.as_slice()
}
"up-users" => {
include_bytes!("../../tests/contracts/dynamic/content/up-users/contract.json")
.as_slice()
}
_ => unreachable!("unknown dynamic content endpoint"),
};
EndpointContract::from_slice(bytes)
}
fn query_map(query: Vec<(&'static str, String)>) -> BTreeMap<String, String> {
query
.into_iter()
.map(|(key, value)| (key.to_string(), value))
.collect()
}
#[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
#[tokio::test]
async fn test_get_live_users() -> Result<(), BpiError> {
let bpi = BpiClient::new().expect("client should build");
let data = bpi
.dynamic()
.live_users(DynamicLiveUsersParams::new().with_size(1)?)
.await?;
info!("直播中的关注者数量: {}", data.count);
info!("第一位直播中的关注者: {:?}", data.items.first());
Ok(())
}
#[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
#[tokio::test]
async fn test_get_dyn_up_users() -> Result<(), BpiError> {
let bpi = BpiClient::new().expect("client should build");
let data = bpi.dynamic().up_users(DynamicUpUsersParams::new()).await?;
info!("发布新动态的关注者列表: {:?}", data.items);
assert!(!data.items.is_empty());
Ok(())
}
#[test]
fn dynamic_content_contracts_match_endpoint_requests() -> BpiResult<()> {
let live_users = contract("live-users")?;
assert_eq!(live_users.name, "dynamic.live_users");
assert_eq!(live_users.request.method, HttpMethod::Get);
assert_eq!(
live_users.request.url.as_str(),
"https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/w_live_users"
);
assert_eq!(
live_users.request.query,
query_map(DynamicLiveUsersParams::new().with_size(1)?.query_pairs())
);
assert_eq!(live_users.cases.len(), 3);
assert_eq!(
live_users.cases[0].response.error.as_deref(),
Some("requires_login")
);
let up_users = contract("up-users")?;
assert_eq!(up_users.name, "dynamic.up_users");
assert_eq!(up_users.request.method, HttpMethod::Get);
assert_eq!(
up_users.request.url.as_str(),
"https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/w_dyn_uplist"
);
assert_eq!(
up_users.request.query,
query_map(DynamicUpUsersParams::new().query_pairs())
);
assert_eq!(up_users.cases.len(), 3);
assert_eq!(
up_users.cases[0].response.error.as_deref(),
Some("requires_login")
);
Ok(())
}
#[test]
fn dynamic_content_response_fixtures_parse_declared_models() -> BpiResult<()> {
for bytes in [
include_bytes!(
"../../tests/contracts/dynamic/content/live-users/responses/normal.success.json"
)
.as_slice(),
include_bytes!(
"../../tests/contracts/dynamic/content/live-users/responses/vip.success.json"
)
.as_slice(),
] {
let payload = ApiEnvelope::<LiveUsersData>::from_slice(bytes)?.into_payload()?;
assert_eq!(payload.group, "default");
}
for bytes in [
include_bytes!(
"../../tests/contracts/dynamic/content/up-users/responses/normal.success.json"
)
.as_slice(),
include_bytes!(
"../../tests/contracts/dynamic/content/up-users/responses/vip.success.json"
)
.as_slice(),
] {
let _ = ApiEnvelope::<DynUpUsersData>::from_slice(bytes)?.into_payload()?;
}
Ok(())
}
#[test]
fn dynamic_content_anonymous_fixtures_record_login_errors() -> BpiResult<()> {
for bytes in [
include_bytes!(
"../../tests/contracts/dynamic/content/live-users/responses/anonymous.requires_login.json"
)
.as_slice(),
include_bytes!(
"../../tests/contracts/dynamic/content/up-users/responses/anonymous.requires_login.json"
)
.as_slice(),
] {
let err = ApiEnvelope::<serde_json::Value>::from_slice(bytes)?
.ensure_success()
.unwrap_err();
assert_eq!(err.code(), Some(4100000));
}
Ok(())
}
fn local_probe_body(endpoint: &str, profile: &str) -> Option<serde_json::Value> {
let path = format!(
"target/bpi-probe-runs/dynamic/content-readonly/{endpoint}/{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 dynamic_content_models_match_local_probe_outputs_when_available() -> BpiResult<()> {
for profile in ["normal", "vip"] {
if let Some(body) = local_probe_body("live-users", profile) {
let payload =
serde_json::from_value::<ApiEnvelope<LiveUsersData>>(body)?.into_payload()?;
assert_eq!(payload.group, "default");
}
if let Some(body) = local_probe_body("up-users", profile) {
let _ =
serde_json::from_value::<ApiEnvelope<DynUpUsersData>>(body)?.into_payload()?;
}
}
for endpoint in ["live-users", "up-users"] {
if let Some(body) = local_probe_body(endpoint, "anonymous") {
let err = serde_json::from_value::<ApiEnvelope<serde_json::Value>>(body)?
.ensure_success()
.unwrap_err();
assert_eq!(err.code(), Some(4100000));
}
}
Ok(())
}
}