1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Deserialize, Serialize)]
5pub struct NoteListArchiveData {
6 #[serde(rename = "noteIds")]
8 pub note_ids: Option<Vec<String>>,
9}
10
11#[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 pub bvid: Option<String>,
23 pub pic: Option<String>,
24 pub desc: Option<String>,
25}
26
27#[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#[derive(Debug, Clone, Deserialize, Serialize)]
46pub struct PrivateNoteListData {
47 pub list: Option<Vec<PrivateNoteItem>>,
48 pub page: Option<NotePage>,
49}
50
51#[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#[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#[derive(Debug, Clone, Deserialize, Serialize)]
80pub struct NotePage {
81 pub total: u32,
82 pub size: u32,
83 pub num: u32,
84}
85
86#[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#[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}