Skip to main content

bpi_rs/note/
list.rs

1use serde::{Deserialize, Serialize};
2
3/// 稿件私有笔记列表数据
4#[derive(Debug, Clone, Deserialize, Serialize)]
5pub struct NoteListArchiveData {
6    /// 笔记ID列表
7    #[serde(rename = "noteIds")]
8    pub note_ids: Option<Vec<String>>,
9}
10
11// --- 查询用户私有笔记 ---
12
13/// 用户私有笔记的视频信息
14#[derive(Debug, Clone, Deserialize, Serialize)]
15pub struct PrivateNoteArc {
16    pub oid: u64,
17    pub status: u8,
18    pub oid_type: u8,
19    pub aid: u64,
20
21    // 老笔记没有以下内容
22    pub bvid: Option<String>,
23    pub pic: Option<String>,
24    pub desc: Option<String>,
25}
26
27/// 用户私有笔记列表项
28#[derive(Debug, Clone, Deserialize, Serialize)]
29pub struct PrivateNoteItem {
30    pub title: String,
31    pub summary: String,
32    pub mtime: String,
33    pub arc: PrivateNoteArc,
34    pub note_id: u64,
35    pub audit_status: u8,
36    pub web_url: String,
37    pub note_id_str: String,
38    pub message: String,
39    pub forbid_note_entrance: Option<bool>,
40    pub likes: u64,
41    pub has_like: bool,
42}
43
44/// 用户私有笔记列表数据
45#[derive(Debug, Clone, Deserialize, Serialize)]
46pub struct PrivateNoteListData {
47    pub list: Option<Vec<PrivateNoteItem>>,
48    pub page: Option<NotePage>,
49}
50
51// --- 查询稿件公开笔记 ---
52
53/// 稿件公开笔记列表项的作者信息
54#[derive(Debug, Clone, Deserialize, Serialize)]
55pub struct PublicNoteAuthor {
56    pub mid: u64,
57    pub name: String,
58    pub face: String,
59    pub level: u8,
60    pub vip_info: serde_json::Value,
61    pub pendant: serde_json::Value,
62}
63
64/// 稿件公开笔记列表项
65#[derive(Debug, Clone, Deserialize, Serialize)]
66pub struct PublicNoteItem {
67    pub cvid: u64,
68    pub title: String,
69    pub summary: String,
70    pub pubtime: String,
71    pub web_url: String,
72    pub message: String,
73    pub author: PublicNoteAuthor,
74    pub likes: u64,
75    pub has_like: bool,
76}
77
78/// 稿件公开笔记分页信息
79#[derive(Debug, Clone, Deserialize, Serialize)]
80pub struct NotePage {
81    pub total: u32,
82    pub size: u32,
83    pub num: u32,
84}
85
86/// 稿件公开笔记列表数据
87#[derive(Debug, Clone, Deserialize, Serialize)]
88pub struct PublicNoteListArchiveData {
89    pub list: Option<Vec<PublicNoteItem>>,
90    pub page: Option<NotePage>,
91    pub show_public_note: bool,
92    pub message: String,
93}
94
95// --- 查询用户公开笔记 ---
96
97/// 用户公开笔记列表数据
98#[derive(Debug, Clone, Deserialize, Serialize)]
99pub struct PublicNoteListUserData {
100    pub list: Option<Vec<PublicNoteItem>>,
101    pub page: Option<NotePage>,
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use crate::ids::Aid;
108    use crate::note::{
109        NoteArchiveListParams, NotePublicArchiveListParams, NoteUserPrivateListParams,
110        NoteUserPublicListParams,
111    };
112    use crate::probe::contract::HttpMethod;
113    use crate::probe::endpoint_contract::EndpointContract;
114    use crate::{ApiEnvelope, BpiClient, BpiError, BpiResult};
115    use base64::{Engine as _, engine::general_purpose};
116    use tracing::info;
117
118    const TEST_PRIVATE_AID: u64 = 676_931_260;
119    const TEST_PUBLIC_AID: u64 = 338_677_252;
120
121    fn contract(endpoint: &str) -> BpiResult<EndpointContract> {
122        let bytes = match endpoint {
123            "archive-list" => {
124                include_bytes!("../../tests/contracts/note/read/archive-list/contract.json")
125                    .as_slice()
126            }
127            "user-private-list" => {
128                include_bytes!("../../tests/contracts/note/read/user-private-list/contract.json")
129                    .as_slice()
130            }
131            "public-archive-list" => {
132                include_bytes!("../../tests/contracts/note/read/public-archive-list/contract.json")
133                    .as_slice()
134            }
135            "user-public-list" => {
136                include_bytes!("../../tests/contracts/note/read/user-public-list/contract.json")
137                    .as_slice()
138            }
139            _ => {
140                return Err(BpiError::invalid_parameter(
141                    "endpoint",
142                    "unknown note list contract",
143                ));
144            }
145        };
146
147        EndpointContract::from_slice(bytes)
148    }
149
150    #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
151    #[tokio::test]
152    async fn test_note_list_archive() {
153        let bpi = BpiClient::new().expect("client should build");
154        let resp = bpi
155            .note()
156            .archive_list(NoteArchiveListParams::new(
157                Aid::new(TEST_PRIVATE_AID).expect("test aid should be valid"),
158            ))
159            .await;
160
161        info!("{:?}", resp);
162        assert!(resp.is_ok());
163
164        let data = resp.unwrap();
165        info!("note ids: {:?}", data.note_ids);
166    }
167
168    #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
169    #[tokio::test]
170    async fn test_note_list_user_private() {
171        let bpi = BpiClient::new().expect("client should build");
172        let resp = bpi
173            .note()
174            .user_private_list(
175                NoteUserPrivateListParams::new()
176                    .with_page(1)
177                    .expect("test page should be valid")
178                    .with_page_size(10)
179                    .expect("test page size should be valid"),
180            )
181            .await;
182
183        info!("{:?}", resp);
184        assert!(resp.is_ok());
185
186        let data = resp.unwrap();
187        if let Some(list) = data.list.as_ref() {
188            info!("first note item: {:?}", list.first());
189        }
190    }
191
192    #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
193    #[tokio::test]
194    async fn test_note_list_public_archive() {
195        let bpi = BpiClient::new().expect("client should build");
196        let resp = bpi
197            .note()
198            .public_archive_list(
199                NotePublicArchiveListParams::new(
200                    Aid::new(TEST_PUBLIC_AID).expect("test aid should be valid"),
201                )
202                .with_page(1)
203                .expect("test page should be valid")
204                .with_page_size(10)
205                .expect("test page size should be valid"),
206            )
207            .await;
208
209        info!("{:?}", resp);
210        assert!(resp.is_ok());
211
212        let data = resp.unwrap();
213        info!("show_public_note: {}", data.show_public_note);
214    }
215
216    #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
217    #[tokio::test]
218    async fn test_note_list_public_user() {
219        let bpi = BpiClient::new().expect("client should build");
220        let resp = bpi
221            .note()
222            .user_public_list(
223                NoteUserPublicListParams::new()
224                    .with_page(1)
225                    .expect("test page should be valid")
226                    .with_page_size(10)
227                    .expect("test page size should be valid"),
228            )
229            .await;
230
231        info!("{:?}", resp);
232        assert!(resp.is_ok());
233
234        let data = resp.unwrap();
235        info!("total public notes: {}", data.page.as_ref().unwrap().total);
236    }
237
238    #[test]
239    fn note_archive_list_params_serializes_aid() -> Result<(), BpiError> {
240        let params = NoteArchiveListParams::new(Aid::new(TEST_PRIVATE_AID)?);
241
242        assert_eq!(
243            params.query_pairs(),
244            vec![
245                ("oid", TEST_PRIVATE_AID.to_string()),
246                ("oid_type", "0".to_string()),
247            ]
248        );
249        Ok(())
250    }
251
252    #[test]
253    fn note_user_private_list_params_rejects_zero_page() {
254        let err = NoteUserPrivateListParams::new().with_page(0).unwrap_err();
255
256        assert!(matches!(
257            err,
258            BpiError::InvalidParameter { field: "pn", .. }
259        ));
260    }
261
262    #[test]
263    fn note_public_archive_list_params_serializes_query() -> Result<(), BpiError> {
264        let params = NotePublicArchiveListParams::new(Aid::new(TEST_PUBLIC_AID)?)
265            .with_page(1)?
266            .with_page_size(10)?;
267
268        assert_eq!(
269            params.query_pairs(),
270            vec![
271                ("oid", TEST_PUBLIC_AID.to_string()),
272                ("oid_type", "0".to_string()),
273                ("pn", "1".to_string()),
274                ("ps", "10".to_string()),
275            ]
276        );
277        Ok(())
278    }
279
280    #[test]
281    fn note_list_contracts_match_endpoint_requests() -> BpiResult<()> {
282        let archive_list = contract("archive-list")?;
283        assert_eq!(archive_list.name, "note.archive_list");
284        assert_eq!(archive_list.request.method, HttpMethod::Get);
285        assert_eq!(
286            archive_list.request.url.as_str(),
287            "https://api.bilibili.com/x/note/list/archive"
288        );
289        assert_eq!(
290            archive_list.request.query.get("oid").map(String::as_str),
291            Some("676931260")
292        );
293        assert_eq!(archive_list.cases[0].response.api_code, Some(-101));
294        assert_eq!(
295            archive_list.cases[1].response.rust_model.as_deref(),
296            Some("NoteListArchiveData")
297        );
298
299        let user_private = contract("user-private-list")?;
300        assert_eq!(user_private.name, "note.user_private_list");
301        assert_eq!(
302            user_private.request.url.as_str(),
303            "https://api.bilibili.com/x/note/list"
304        );
305        assert_eq!(
306            user_private.request.query.get("pn").map(String::as_str),
307            Some("1")
308        );
309        assert_eq!(
310            user_private.cases[1].response.rust_model.as_deref(),
311            Some("PrivateNoteListData")
312        );
313
314        let public_archive = contract("public-archive-list")?;
315        assert_eq!(public_archive.name, "note.public_archive_list");
316        assert_eq!(
317            public_archive.request.url.as_str(),
318            "https://api.bilibili.com/x/note/publish/list/archive"
319        );
320        assert_eq!(
321            public_archive.cases[0].response.rust_model.as_deref(),
322            Some("PublicNoteListArchiveData")
323        );
324
325        let user_public = contract("user-public-list")?;
326        assert_eq!(user_public.name, "note.user_public_list");
327        assert_eq!(
328            user_public.request.url.as_str(),
329            "https://api.bilibili.com/x/note/publish/list/user"
330        );
331        assert_eq!(user_public.cases[0].response.http_status, Some(200));
332        assert_eq!(
333            user_public.cases[0].response.error.as_deref(),
334            Some("requires_login")
335        );
336        assert_eq!(
337            user_public.cases[1].response.rust_model.as_deref(),
338            Some("PublicNoteListUserData")
339        );
340        Ok(())
341    }
342
343    #[test]
344    fn note_list_response_fixtures_parse_declared_models() -> BpiResult<()> {
345        let err = ApiEnvelope::<serde_json::Value>::from_slice(include_bytes!(
346            "../../tests/contracts/note/read/archive-list/responses/anonymous.requires_login.json"
347        ))
348        .and_then(ApiEnvelope::ensure_success)
349        .unwrap_err();
350        assert!(err.requires_login());
351
352        let archive = ApiEnvelope::<NoteListArchiveData>::from_slice(include_bytes!(
353            "../../tests/contracts/note/read/archive-list/responses/authenticated.success.json"
354        ))?
355        .into_payload()?;
356        assert_eq!(
357            archive
358                .note_ids
359                .as_ref()
360                .and_then(|note_ids| note_ids.first())
361                .map(String::as_str),
362            Some("1")
363        );
364
365        let private_list = ApiEnvelope::<PrivateNoteListData>::from_slice(include_bytes!(
366            "../../tests/contracts/note/read/user-private-list/responses/authenticated.success.json"
367        ))?
368        .into_payload()?;
369        assert_eq!(
370            private_list
371                .list
372                .as_ref()
373                .and_then(|items| items.first())
374                .map(|item| item.title.as_str()),
375            Some("sanitized private note title")
376        );
377
378        let public_archive = ApiEnvelope::<PublicNoteListArchiveData>::from_slice(include_bytes!(
379            "../../tests/contracts/note/read/public-archive-list/responses/closed.success.json"
380        ))?
381        .into_payload()?;
382        assert!(!public_archive.show_public_note);
383
384        let binary: serde_json::Value = serde_json::from_slice(include_bytes!(
385            "../../tests/contracts/note/read/user-public-list/responses/anonymous.requires_login.binary.json"
386        ))?;
387        assert_eq!(binary["kind"], "binary");
388        let decoded = general_purpose::STANDARD
389            .decode(
390                binary["body_base64"]
391                    .as_str()
392                    .ok_or_else(|| BpiError::unsupported_response("missing binary body"))?,
393            )
394            .map_err(|err| BpiError::parse(err.to_string()))?;
395        let decoded_text =
396            String::from_utf8(decoded).map_err(|err| BpiError::parse(err.to_string()))?;
397        assert!(decoded_text.contains("\"code\":-101"));
398
399        let public_user = ApiEnvelope::<PublicNoteListUserData>::from_slice(include_bytes!(
400            "../../tests/contracts/note/read/user-public-list/responses/authenticated.success.json"
401        ))?
402        .into_payload()?;
403        assert_eq!(public_user.page.as_ref().map(|page| page.total), Some(0));
404        assert!(public_user.list.is_none());
405        Ok(())
406    }
407
408    fn local_probe_body(endpoint: &str, profile: &str) -> Option<serde_json::Value> {
409        let path = format!("target/bpi-probe-runs/note/read/{endpoint}/{profile}.response.json");
410        let bytes = std::fs::read(path).ok()?;
411        let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
412        value
413            .get("response")
414            .and_then(|response| response.get("body"))
415            .cloned()
416    }
417
418    #[test]
419    fn note_list_models_match_local_probe_outputs_when_available() -> BpiResult<()> {
420        for profile in ["anonymous", "normal", "vip"] {
421            let Some(body) = local_probe_body("archive-list", profile) else {
422                continue;
423            };
424            if profile == "anonymous" {
425                let err = serde_json::from_value::<ApiEnvelope<serde_json::Value>>(body)?
426                    .ensure_success()
427                    .unwrap_err();
428                assert!(err.requires_login());
429                continue;
430            }
431            serde_json::from_value::<ApiEnvelope<NoteListArchiveData>>(body)?.into_payload()?;
432        }
433
434        for profile in ["anonymous", "normal", "vip"] {
435            let Some(body) = local_probe_body("user-private-list", profile) else {
436                continue;
437            };
438            if profile == "anonymous" {
439                let err = serde_json::from_value::<ApiEnvelope<serde_json::Value>>(body)?
440                    .ensure_success()
441                    .unwrap_err();
442                assert!(err.requires_login());
443                continue;
444            }
445            serde_json::from_value::<ApiEnvelope<PrivateNoteListData>>(body)?.into_payload()?;
446        }
447
448        for profile in ["anonymous", "normal", "vip"] {
449            let Some(body) = local_probe_body("public-archive-list", profile) else {
450                continue;
451            };
452            serde_json::from_value::<ApiEnvelope<PublicNoteListArchiveData>>(body)?
453                .into_payload()?;
454        }
455
456        for profile in ["anonymous", "normal", "vip"] {
457            let Some(body) = local_probe_body("user-public-list", profile) else {
458                continue;
459            };
460            if profile == "anonymous" {
461                assert_eq!(body["kind"], "binary");
462                continue;
463            }
464            serde_json::from_value::<ApiEnvelope<PublicNoteListUserData>>(body)?.into_payload()?;
465        }
466        Ok(())
467    }
468}