use crate::{BpiError, BpiResult};
use serde::{Deserialize, Serialize};
const DEFAULT_PLATFORM_FILTER: &str = "1,3";
const DEFAULT_MOLD: u32 = 0;
const DEFAULT_HTTP_MODE: u32 = 3;
const DEFAULT_PAGE: u32 = 1;
const DEFAULT_PAGE_SIZE: u32 = 15;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActivityListData {
pub list: Vec<ActivityItem>,
pub num: i32,
pub size: i32,
pub total: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActivityItem {
pub id: i32,
pub state: i32,
pub stime: i64,
pub etime: i64,
pub ctime: i64,
pub mtime: i64,
pub name: String,
pub h5_url: String,
pub h5_cover: String,
pub page_name: String,
pub plat: i32,
pub desc: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActivityListParams {
plat: String,
mold: u32,
http: u32,
pn: u32,
ps: u32,
}
impl Default for ActivityListParams {
fn default() -> Self {
Self {
plat: DEFAULT_PLATFORM_FILTER.to_string(),
mold: DEFAULT_MOLD,
http: DEFAULT_HTTP_MODE,
pn: DEFAULT_PAGE,
ps: DEFAULT_PAGE_SIZE,
}
}
}
impl ActivityListParams {
pub fn new() -> Self {
Self::default()
}
pub fn platform_filter(mut self, plat: impl Into<String>) -> BpiResult<Self> {
let plat = plat.into();
validate_non_blank("plat", &plat)?;
self.plat = plat;
Ok(self)
}
pub fn mold(mut self, mold: u32) -> Self {
self.mold = mold;
self
}
pub fn http_mode(mut self, http: u32) -> Self {
self.http = http;
self
}
pub fn page(mut self, page: u32) -> BpiResult<Self> {
self.pn = validate_positive("pn", page)?;
Ok(self)
}
pub fn page_size(mut self, page_size: u32) -> BpiResult<Self> {
self.ps = validate_positive("ps", page_size)?;
Ok(self)
}
pub(crate) fn query_pairs(&self) -> Vec<(&'static str, String)> {
vec![
("plat", self.plat.clone()),
("mold", self.mold.to_string()),
("http", self.http.to_string()),
("pn", self.pn.to_string()),
("ps", self.ps.to_string()),
]
}
}
fn validate_non_blank(field: &'static str, value: &str) -> BpiResult<()> {
if value.trim().is_empty() {
return Err(BpiError::invalid_parameter(field, "value cannot be blank"));
}
Ok(())
}
fn validate_positive(field: &'static str, value: u32) -> BpiResult<u32> {
if value == 0 {
return Err(BpiError::invalid_parameter(field, "value must be non-zero"));
}
Ok(value)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::probe::contract::HttpMethod;
use crate::probe::endpoint_contract::EndpointContract;
use crate::{ApiEnvelope, BpiClient, BpiResult};
fn contract() -> BpiResult<EndpointContract> {
EndpointContract::from_slice(include_bytes!(
"../../tests/contracts/activity/list/contract.json"
))
}
#[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
#[tokio::test]
async fn test_get_activity_list() -> Result<(), Box<BpiError>> {
let bpi = BpiClient::new().expect("client should build");
let params = ActivityListParams::new().page_size(4)?;
let data = bpi.activity().list(params).await?;
tracing::info!("{:#?}", data);
assert!(!data.list.is_empty());
assert_eq!(data.num, 1);
assert_eq!(data.size, 4);
assert!(data.total > 0);
Ok(())
}
#[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
#[tokio::test]
async fn test_get_activity_list_simple() -> Result<(), Box<BpiError>> {
let bpi = BpiClient::new().expect("client should build");
let data = bpi.activity().list_default().await?;
tracing::info!("{:#?}", data);
assert!(!data.list.is_empty());
assert_eq!(data.num, 1);
assert_eq!(data.size, 15);
Ok(())
}
#[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
#[tokio::test]
async fn test_activity_item_fields() -> Result<(), Box<BpiError>> {
let bpi = BpiClient::new().expect("client should build");
let params = ActivityListParams::new().page_size(1)?;
let data = bpi.activity().list(params).await?;
tracing::info!("{:#?}", data);
if let Some(activity) = data.list.first() {
assert!(activity.id > 0);
assert_eq!(activity.state, 1);
assert!(!activity.name.is_empty());
assert!(!activity.page_name.is_empty());
}
Ok(())
}
#[test]
fn activity_list_params_serializes_defaults() {
let params = ActivityListParams::new();
assert_eq!(
params.query_pairs(),
vec![
("plat", "1,3".to_string()),
("mold", "0".to_string()),
("http", "3".to_string()),
("pn", "1".to_string()),
("ps", "15".to_string()),
]
);
}
#[test]
fn activity_list_params_serializes_custom_values() -> Result<(), BpiError> {
let params = ActivityListParams::new()
.platform_filter("1")?
.mold(2)
.http_mode(4)
.page(3)?
.page_size(30)?;
assert_eq!(
params.query_pairs(),
vec![
("plat", "1".to_string()),
("mold", "2".to_string()),
("http", "4".to_string()),
("pn", "3".to_string()),
("ps", "30".to_string()),
]
);
Ok(())
}
#[test]
fn activity_list_params_rejects_blank_platform_filter() {
let err = ActivityListParams::new().platform_filter(" ").unwrap_err();
assert!(matches!(
err,
BpiError::InvalidParameter { field: "plat", .. }
));
}
#[test]
fn activity_list_params_rejects_zero_page() {
let err = ActivityListParams::new().page(0).unwrap_err();
assert!(matches!(
err,
BpiError::InvalidParameter { field: "pn", .. }
));
}
#[test]
fn activity_list_contract_matches_endpoint_request() -> BpiResult<()> {
let contract = contract()?;
assert_eq!(contract.name, "activity.list");
assert_eq!(contract.request.method, HttpMethod::Get);
assert_eq!(
contract.request.url.as_str(),
"https://api.bilibili.com/x/activity/page/list"
);
assert_eq!(
contract.request.query.get("plat").map(String::as_str),
Some(DEFAULT_PLATFORM_FILTER)
);
assert_eq!(
contract.request.query.get("ps").map(String::as_str),
Some("1")
);
assert_eq!(contract.cases.len(), 3);
assert_eq!(
contract.cases[0].response.rust_model.as_deref(),
Some("ActivityListData")
);
Ok(())
}
#[test]
fn activity_list_response_fixtures_parse_declared_model() -> BpiResult<()> {
for bytes in [
include_bytes!("../../tests/contracts/activity/list/responses/anonymous.success.json")
.as_slice(),
include_bytes!("../../tests/contracts/activity/list/responses/normal.success.json")
.as_slice(),
include_bytes!("../../tests/contracts/activity/list/responses/vip.success.json")
.as_slice(),
] {
let payload = ApiEnvelope::<ActivityListData>::from_slice(bytes)?.into_payload()?;
assert_eq!(payload.num, 1);
assert_eq!(payload.size, 1);
assert_eq!(payload.list.len(), 1);
}
Ok(())
}
fn local_probe_body(profile: &str) -> Option<serde_json::Value> {
let path = format!("target/bpi-probe-runs/activity/public/list/{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 activity_list_model_matches_local_probe_outputs_when_available() -> BpiResult<()> {
for profile in ["anonymous", "normal", "vip"] {
let Some(body) = local_probe_body(profile) else {
continue;
};
let payload =
serde_json::from_value::<ApiEnvelope<ActivityListData>>(body)?.into_payload()?;
assert_eq!(payload.num, 1);
assert_eq!(payload.size, 1);
assert_eq!(payload.list.len(), 1);
}
Ok(())
}
}