1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Deserialize, Serialize)]
7pub struct FavFolderUpper {
8 pub mid: u64,
9 pub name: String,
10 pub face: String,
11 pub followed: bool,
12 pub vip_type: u8,
13 #[serde(rename = "vip_statue")]
15 pub vip_status: u8,
16}
17
18#[derive(Debug, Clone, Deserialize, Serialize)]
20pub struct FavFolderCntInfo {
21 pub collect: u64,
22 pub play: u64,
23 pub thumb_up: u64,
24 pub share: u64,
25}
26
27#[derive(Debug, Clone, Deserialize, Serialize)]
29pub struct FavFolderInfo {
30 pub id: u64,
31 pub fid: u64,
32 pub mid: u64,
33 pub attr: u32,
34 pub title: String,
35 pub cover: String,
36 pub upper: FavFolderUpper,
37 pub cover_type: u8,
38 pub cnt_info: FavFolderCntInfo,
39 #[serde(rename = "type")]
40 pub type_name: u32,
41 pub intro: String,
42 pub ctime: u64,
43 pub mtime: u64,
44 pub state: u8,
45 pub fav_state: u8,
46 pub like_state: u8,
47 pub media_count: u32,
48}
49
50#[derive(Debug, Clone, Deserialize, Serialize)]
54pub struct CreatedFolderItem {
55 pub id: u64,
56 pub fid: u64,
57 pub mid: u64,
58 pub attr: u32,
59 pub title: String,
60 pub fav_state: u8,
61 pub media_count: u32,
62}
63
64#[derive(Debug, Clone, Deserialize, Serialize)]
66pub struct CreatedFolderListData {
67 pub count: u32,
68 pub list: Vec<CreatedFolderItem>,
69}
70
71#[derive(Debug, Clone, Deserialize, Serialize)]
75pub struct CollectedFolderUpper {
76 pub mid: u64,
77 pub name: String,
78 pub face: String,
79}
80
81#[derive(Debug, Clone, Deserialize, Serialize)]
83pub struct CollectedFolderItem {
84 pub id: u64,
85 pub fid: u64,
86 pub mid: u64,
87 pub attr: u32,
88 pub title: String,
89 pub cover: String,
90 pub upper: CollectedFolderUpper,
91 pub cover_type: u8,
92 pub intro: String,
93 pub ctime: u64,
94 pub mtime: u64,
95 pub state: u8,
96 pub fav_state: u8,
97 pub media_count: u32,
98}
99
100#[derive(Debug, Clone, Deserialize, Serialize)]
102pub struct CollectedFolderListData {
103 pub count: u32,
104 pub list: Vec<CollectedFolderItem>,
105}
106
107#[derive(Debug, Clone, Deserialize, Serialize)]
111pub struct ResourceInfoUpper {
112 pub mid: u64,
113 pub name: String,
114 pub face: String,
115}
116
117#[derive(Debug, Clone, Deserialize, Serialize)]
119pub struct ResourceInfoCntInfo {
120 pub collect: u64,
121 pub play: u64,
122 pub danmaku: u64,
123}
124
125#[derive(Debug, Clone, Deserialize, Serialize)]
127pub struct ResourceInfoItem {
128 pub id: u64,
129 #[serde(rename = "type")]
130 pub type_name: u8,
131 pub title: String,
132 pub cover: String,
133 pub intro: String,
134 pub page: Option<u32>,
135 pub duration: u32,
136 pub upper: ResourceInfoUpper,
137 pub attr: u8,
138 pub cnt_info: ResourceInfoCntInfo,
139 pub link: String,
140 pub ctime: u64,
141 pub pubtime: u64,
142 pub fav_time: u64,
143 pub bv_id: Option<String>,
144 pub bvid: Option<String>,
145 pub season: Option<serde_json::Value>,
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151 use crate::fav::params::{
152 FavCollectedListParams, FavCreatedListParams, FavFolderInfoParams, FavResourceInfosParams,
153 };
154 use crate::probe::contract::HttpMethod;
155 use crate::probe::endpoint_contract::EndpointContract;
156 use crate::{ApiEnvelope, BpiClient, BpiResult};
157 use tracing::info;
158
159 fn contract(endpoint: &str) -> BpiResult<EndpointContract> {
160 let bytes = match endpoint {
161 "folder-info" => {
162 include_bytes!("../../tests/contracts/fav/read/folder-info/contract.json")
163 .as_slice()
164 }
165 "created-list" => {
166 include_bytes!("../../tests/contracts/fav/read/created-list/contract.json")
167 .as_slice()
168 }
169 "collected-list" => {
170 include_bytes!("../../tests/contracts/fav/read/collected-list/contract.json")
171 .as_slice()
172 }
173 "resource-infos" => {
174 include_bytes!("../../tests/contracts/fav/read/resource-infos/contract.json")
175 .as_slice()
176 }
177 _ => unreachable!("unknown fav info contract endpoint"),
178 };
179
180 EndpointContract::from_slice(bytes)
181 }
182
183 #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
184 #[tokio::test]
185 async fn test_get_fav_folder_info() {
186 let bpi = BpiClient::new().expect("client should build");
187 let params = FavFolderInfoParams::new(
189 crate::ids::MediaId::new(1052622027).expect("fixture media id should be valid"),
190 );
191 let resp = bpi.fav().folder_info(params).await;
192
193 info!("{:?}", resp);
194 assert!(resp.is_ok());
195
196 let data = resp.unwrap();
197 info!("folder title: {}", data.title);
198 info!("folder media_count: {}", data.media_count);
199 info!("upper info: {:?}", data.upper);
200 }
201
202 #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
203 #[tokio::test]
204 async fn test_get_fav_created_list() {
205 let bpi = BpiClient::new().expect("client should build");
206
207 let params = FavCreatedListParams::new(
208 crate::ids::Mid::new(7792521).expect("fixture mid should be valid"),
209 );
210 let resp = bpi.fav().created_list(params).await;
211
212 info!("{:?}", resp);
213 assert!(resp.is_ok());
214
215 let data = resp.unwrap();
216 info!("created folders count: {}", data.count);
217 info!("first folder info: {:?}", data.list.first());
218 }
219
220 #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
221 #[tokio::test]
222 async fn test_get_fav_collected_list() {
223 let bpi = BpiClient::new().expect("client should build");
224
225 let params = FavCollectedListParams::new(
226 crate::ids::Mid::new(7792521).expect("fixture mid should be valid"),
227 )
228 .with_page(1)
229 .expect("fixture page should be valid")
230 .with_page_size(20)
231 .expect("fixture page size should be valid");
232 let resp = bpi.fav().collected_list(params).await;
233
234 info!("{:?}", resp);
235 assert!(resp.is_ok());
236
237 let data = resp.unwrap();
238 info!("collected folders count: {}", data.count);
239 info!("first collected folder info: {:?}", data.list.first());
240 }
241
242 #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
243 #[tokio::test]
244 async fn test_get_fav_resource_infos() {
245 let bpi = BpiClient::new().expect("client should build");
246 let params =
247 FavResourceInfosParams::new("371494037:2").expect("fixture resources should be valid");
248 let resp = bpi.fav().resource_infos(params).await;
249
250 info!("{:?}", resp);
251 assert!(resp.is_ok());
252
253 let data = resp.unwrap();
254 info!("retrieved {} resources", data.len());
255 info!("first resource info: {:?}", data.first());
256 }
257
258 #[test]
259 fn fav_folder_info_contract_matches_endpoint_request() -> BpiResult<()> {
260 let contract = contract("folder-info")?;
261
262 assert_eq!(contract.name, "fav.folder_info");
263 assert_eq!(contract.request.method, HttpMethod::Get);
264 assert_eq!(
265 contract.request.url.as_str(),
266 "https://api.bilibili.com/x/v3/fav/folder/info"
267 );
268 assert_eq!(
269 contract.request.query.get("media_id").map(String::as_str),
270 Some("1052622027")
271 );
272 assert_eq!(contract.cases.len(), 3);
273 assert_eq!(
274 contract.cases[0].response.rust_model.as_deref(),
275 Some("FavFolderInfo")
276 );
277 Ok(())
278 }
279
280 #[test]
281 fn fav_created_list_contract_matches_endpoint_request() -> BpiResult<()> {
282 let contract = contract("created-list")?;
283
284 assert_eq!(contract.name, "fav.created_list");
285 assert_eq!(contract.request.method, HttpMethod::Get);
286 assert_eq!(
287 contract.request.url.as_str(),
288 "https://api.bilibili.com/x/v3/fav/folder/created/list-all"
289 );
290 assert_eq!(
291 contract.request.query.get("up_mid").map(String::as_str),
292 Some("7792521")
293 );
294 assert_eq!(contract.cases.len(), 3);
295 assert_eq!(
296 contract.cases[0].response.rust_model.as_deref(),
297 Some("CreatedFolderListData")
298 );
299 Ok(())
300 }
301
302 #[test]
303 fn fav_collected_list_contract_matches_endpoint_request() -> BpiResult<()> {
304 let contract = contract("collected-list")?;
305
306 assert_eq!(contract.name, "fav.collected_list");
307 assert_eq!(contract.request.method, HttpMethod::Get);
308 assert_eq!(
309 contract.request.url.as_str(),
310 "https://api.bilibili.com/x/v3/fav/folder/collected/list"
311 );
312 assert_eq!(
313 contract.request.query.get("up_mid").map(String::as_str),
314 Some("7792521")
315 );
316 assert_eq!(
317 contract.request.query.get("platform").map(String::as_str),
318 Some("web")
319 );
320 assert_eq!(contract.cases.len(), 3);
321 assert_eq!(
322 contract.cases[0].response.rust_model.as_deref(),
323 Some("CollectedFolderListData")
324 );
325 Ok(())
326 }
327
328 #[test]
329 fn fav_resource_infos_contract_matches_endpoint_request() -> BpiResult<()> {
330 let contract = contract("resource-infos")?;
331
332 assert_eq!(contract.name, "fav.resource_infos");
333 assert_eq!(contract.request.method, HttpMethod::Get);
334 assert_eq!(
335 contract.request.url.as_str(),
336 "https://api.bilibili.com/x/v3/fav/resource/infos"
337 );
338 assert_eq!(
339 contract.request.query.get("resources").map(String::as_str),
340 Some("371494037:2")
341 );
342 assert_eq!(contract.cases.len(), 3);
343 assert_eq!(
344 contract.cases[0].response.rust_model.as_deref(),
345 Some("Vec<ResourceInfoItem>")
346 );
347 Ok(())
348 }
349
350 #[test]
351 fn fav_info_response_fixtures_parse_declared_models() -> BpiResult<()> {
352 let folder = ApiEnvelope::<FavFolderInfo>::from_slice(include_bytes!(
353 "../../tests/contracts/fav/read/folder-info/responses/success.json"
354 ))?
355 .into_payload()?;
356 assert_eq!(folder.id, 1052622027);
357
358 let created = ApiEnvelope::<CreatedFolderListData>::from_slice(include_bytes!(
359 "../../tests/contracts/fav/read/created-list/responses/success.json"
360 ))?
361 .into_payload()?;
362 assert_eq!(created.list.len(), 1);
363
364 let collected = ApiEnvelope::<CollectedFolderListData>::from_slice(include_bytes!(
365 "../../tests/contracts/fav/read/collected-list/responses/success.json"
366 ))?
367 .into_payload()?;
368 assert!(collected.list.is_empty());
369
370 let resources = ApiEnvelope::<Vec<ResourceInfoItem>>::from_slice(include_bytes!(
371 "../../tests/contracts/fav/read/resource-infos/responses/success.json"
372 ))?
373 .into_payload()?;
374 assert_eq!(resources.len(), 1);
375 Ok(())
376 }
377
378 fn local_probe_body(endpoint: &str, profile: &str) -> Option<serde_json::Value> {
379 let path = format!("target/bpi-probe-runs/fav/read/{endpoint}/{profile}.response.json");
380 let bytes = std::fs::read(path).ok()?;
381 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
382 value
383 .get("response")
384 .and_then(|response| response.get("body"))
385 .cloned()
386 }
387
388 #[test]
389 fn fav_info_models_match_local_probe_outputs_when_available() -> BpiResult<()> {
390 for profile in ["anonymous", "normal", "vip"] {
391 if let Some(body) = local_probe_body("folder-info", profile) {
392 let payload =
393 serde_json::from_value::<ApiEnvelope<FavFolderInfo>>(body)?.into_payload()?;
394 assert_eq!(payload.id, 1052622027);
395 }
396
397 if let Some(body) = local_probe_body("created-list", profile) {
398 let payload = serde_json::from_value::<ApiEnvelope<CreatedFolderListData>>(body)?
399 .into_payload()?;
400 assert!(payload.count >= payload.list.len() as u32);
401 }
402
403 if let Some(body) = local_probe_body("collected-list", profile) {
404 let payload = serde_json::from_value::<ApiEnvelope<CollectedFolderListData>>(body)?
405 .into_payload()?;
406 assert!(payload.count >= payload.list.len() as u32);
407 }
408
409 if let Some(body) = local_probe_body("resource-infos", profile) {
410 let payload = serde_json::from_value::<ApiEnvelope<Vec<ResourceInfoItem>>>(body)?
411 .into_payload()?;
412 assert!(!payload.is_empty());
413 }
414 }
415 Ok(())
416 }
417}