1use crate::{BpiError, BpiResult};
6use serde::{Deserialize, Serialize};
7
8const DEFAULT_PLATFORM_FILTER: &str = "1,3";
9const DEFAULT_MOLD: u32 = 0;
10const DEFAULT_HTTP_MODE: u32 = 3;
11const DEFAULT_PAGE: u32 = 1;
12const DEFAULT_PAGE_SIZE: u32 = 15;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct ActivityListData {
17 pub list: Vec<ActivityItem>,
19 pub num: i32,
21 pub size: i32,
23 pub total: i32,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct ActivityItem {
30 pub id: i32,
32 pub state: i32,
34 pub stime: i64,
36 pub etime: i64,
38 pub ctime: i64,
40 pub mtime: i64,
42 pub name: String,
44 pub h5_url: String,
46 pub h5_cover: String,
48 pub page_name: String,
50 pub plat: i32,
52 pub desc: String,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct ActivityListParams {
58 plat: String,
59 mold: u32,
60 http: u32,
61 pn: u32,
62 ps: u32,
63}
64
65impl Default for ActivityListParams {
66 fn default() -> Self {
67 Self {
68 plat: DEFAULT_PLATFORM_FILTER.to_string(),
69 mold: DEFAULT_MOLD,
70 http: DEFAULT_HTTP_MODE,
71 pn: DEFAULT_PAGE,
72 ps: DEFAULT_PAGE_SIZE,
73 }
74 }
75}
76
77impl ActivityListParams {
78 pub fn new() -> Self {
80 Self::default()
81 }
82
83 pub fn platform_filter(mut self, plat: impl Into<String>) -> BpiResult<Self> {
85 let plat = plat.into();
86 validate_non_blank("plat", &plat)?;
87 self.plat = plat;
88 Ok(self)
89 }
90
91 pub fn mold(mut self, mold: u32) -> Self {
93 self.mold = mold;
94 self
95 }
96
97 pub fn http_mode(mut self, http: u32) -> Self {
99 self.http = http;
100 self
101 }
102
103 pub fn page(mut self, page: u32) -> BpiResult<Self> {
105 self.pn = validate_positive("pn", page)?;
106 Ok(self)
107 }
108
109 pub fn page_size(mut self, page_size: u32) -> BpiResult<Self> {
111 self.ps = validate_positive("ps", page_size)?;
112 Ok(self)
113 }
114
115 pub(crate) fn query_pairs(&self) -> Vec<(&'static str, String)> {
116 vec![
117 ("plat", self.plat.clone()),
118 ("mold", self.mold.to_string()),
119 ("http", self.http.to_string()),
120 ("pn", self.pn.to_string()),
121 ("ps", self.ps.to_string()),
122 ]
123 }
124}
125
126fn validate_non_blank(field: &'static str, value: &str) -> BpiResult<()> {
127 if value.trim().is_empty() {
128 return Err(BpiError::invalid_parameter(field, "value cannot be blank"));
129 }
130
131 Ok(())
132}
133
134fn validate_positive(field: &'static str, value: u32) -> BpiResult<u32> {
135 if value == 0 {
136 return Err(BpiError::invalid_parameter(field, "value must be non-zero"));
137 }
138
139 Ok(value)
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145 use crate::probe::contract::HttpMethod;
146 use crate::probe::endpoint_contract::EndpointContract;
147 use crate::{ApiEnvelope, BpiClient, BpiResult};
148
149 fn contract() -> BpiResult<EndpointContract> {
150 EndpointContract::from_slice(include_bytes!(
151 "../../tests/contracts/activity/list/contract.json"
152 ))
153 }
154
155 #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
156 #[tokio::test]
157 async fn test_get_activity_list() -> Result<(), Box<BpiError>> {
158 let bpi = BpiClient::new().expect("client should build");
159
160 let params = ActivityListParams::new().page_size(4)?;
162 let data = bpi.activity().list(params).await?;
163 tracing::info!("{:#?}", data);
164
165 assert!(!data.list.is_empty());
166 assert_eq!(data.num, 1);
167 assert_eq!(data.size, 4);
168 assert!(data.total > 0);
169
170 Ok(())
171 }
172
173 #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
174 #[tokio::test]
175 async fn test_get_activity_list_simple() -> Result<(), Box<BpiError>> {
176 let bpi = BpiClient::new().expect("client should build");
177
178 let data = bpi.activity().list_default().await?;
180 tracing::info!("{:#?}", data);
181
182 assert!(!data.list.is_empty());
183 assert_eq!(data.num, 1);
184 assert_eq!(data.size, 15);
185
186 Ok(())
187 }
188
189 #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
190 #[tokio::test]
191 async fn test_activity_item_fields() -> Result<(), Box<BpiError>> {
192 let bpi = BpiClient::new().expect("client should build");
193
194 let params = ActivityListParams::new().page_size(1)?;
195 let data = bpi.activity().list(params).await?;
196 tracing::info!("{:#?}", data);
197
198 if let Some(activity) = data.list.first() {
199 assert!(activity.id > 0);
200 assert_eq!(activity.state, 1);
201 assert!(!activity.name.is_empty());
202 assert!(!activity.page_name.is_empty());
203 }
204
205 Ok(())
206 }
207
208 #[test]
209 fn activity_list_params_serializes_defaults() {
210 let params = ActivityListParams::new();
211
212 assert_eq!(
213 params.query_pairs(),
214 vec![
215 ("plat", "1,3".to_string()),
216 ("mold", "0".to_string()),
217 ("http", "3".to_string()),
218 ("pn", "1".to_string()),
219 ("ps", "15".to_string()),
220 ]
221 );
222 }
223
224 #[test]
225 fn activity_list_params_serializes_custom_values() -> Result<(), BpiError> {
226 let params = ActivityListParams::new()
227 .platform_filter("1")?
228 .mold(2)
229 .http_mode(4)
230 .page(3)?
231 .page_size(30)?;
232
233 assert_eq!(
234 params.query_pairs(),
235 vec![
236 ("plat", "1".to_string()),
237 ("mold", "2".to_string()),
238 ("http", "4".to_string()),
239 ("pn", "3".to_string()),
240 ("ps", "30".to_string()),
241 ]
242 );
243 Ok(())
244 }
245
246 #[test]
247 fn activity_list_params_rejects_blank_platform_filter() {
248 let err = ActivityListParams::new().platform_filter(" ").unwrap_err();
249
250 assert!(matches!(
251 err,
252 BpiError::InvalidParameter { field: "plat", .. }
253 ));
254 }
255
256 #[test]
257 fn activity_list_params_rejects_zero_page() {
258 let err = ActivityListParams::new().page(0).unwrap_err();
259
260 assert!(matches!(
261 err,
262 BpiError::InvalidParameter { field: "pn", .. }
263 ));
264 }
265
266 #[test]
267 fn activity_list_contract_matches_endpoint_request() -> BpiResult<()> {
268 let contract = contract()?;
269
270 assert_eq!(contract.name, "activity.list");
271 assert_eq!(contract.request.method, HttpMethod::Get);
272 assert_eq!(
273 contract.request.url.as_str(),
274 "https://api.bilibili.com/x/activity/page/list"
275 );
276 assert_eq!(
277 contract.request.query.get("plat").map(String::as_str),
278 Some(DEFAULT_PLATFORM_FILTER)
279 );
280 assert_eq!(
281 contract.request.query.get("ps").map(String::as_str),
282 Some("1")
283 );
284 assert_eq!(contract.cases.len(), 3);
285 assert_eq!(
286 contract.cases[0].response.rust_model.as_deref(),
287 Some("ActivityListData")
288 );
289 Ok(())
290 }
291
292 #[test]
293 fn activity_list_response_fixtures_parse_declared_model() -> BpiResult<()> {
294 for bytes in [
295 include_bytes!("../../tests/contracts/activity/list/responses/anonymous.success.json")
296 .as_slice(),
297 include_bytes!("../../tests/contracts/activity/list/responses/normal.success.json")
298 .as_slice(),
299 include_bytes!("../../tests/contracts/activity/list/responses/vip.success.json")
300 .as_slice(),
301 ] {
302 let payload = ApiEnvelope::<ActivityListData>::from_slice(bytes)?.into_payload()?;
303
304 assert_eq!(payload.num, 1);
305 assert_eq!(payload.size, 1);
306 assert_eq!(payload.list.len(), 1);
307 }
308 Ok(())
309 }
310
311 fn local_probe_body(profile: &str) -> Option<serde_json::Value> {
312 let path = format!("target/bpi-probe-runs/activity/public/list/{profile}.response.json");
313 let bytes = std::fs::read(path).ok()?;
314 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
315 value
316 .get("response")
317 .and_then(|response| response.get("body"))
318 .cloned()
319 }
320
321 #[test]
322 fn activity_list_model_matches_local_probe_outputs_when_available() -> BpiResult<()> {
323 for profile in ["anonymous", "normal", "vip"] {
324 let Some(body) = local_probe_body(profile) else {
325 continue;
326 };
327 let payload =
328 serde_json::from_value::<ApiEnvelope<ActivityListData>>(body)?.into_payload()?;
329
330 assert_eq!(payload.num, 1);
331 assert_eq!(payload.size, 1);
332 assert_eq!(payload.list.len(), 1);
333 }
334 Ok(())
335 }
336}