1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Deserialize, Serialize)]
7pub struct NoteIsForbidData {
8 pub forbid_note_entrance: bool,
10}
11
12#[derive(Debug, Clone, Deserialize, Serialize)]
16pub struct PrivateNoteArc {
17 pub oid: u64,
18 pub oid_type: u8,
19 pub title: String,
20 pub pic: String,
21 pub status: u32,
22 pub desc: String,
23}
24
25#[derive(Debug, Clone, Deserialize, Serialize)]
27pub struct PrivateNoteTag {
28 pub cid: u64,
29 pub status: u8,
30 pub index: u32,
31 pub seconds: u32,
32 pub pos: u32,
33}
34
35#[derive(Debug, Clone, Deserialize, Serialize)]
37pub struct PrivateNoteInfoData {
38 pub arc: PrivateNoteArc,
39 pub audit_status: u8,
40 pub cid_count: u32,
41 pub content: String,
42 pub forbid_note_entrance: bool,
43 pub pub_reason: Option<String>,
44 pub pub_status: u8,
45 pub pub_version: u32,
46 pub summary: String,
47 pub tags: Vec<PrivateNoteTag>,
48 pub title: String,
49}
50
51#[derive(Debug, Clone, Deserialize, Serialize)]
55pub struct PublicNoteArc {
56 pub oid: u64,
57 pub oid_type: u8,
58 pub title: String,
59 pub status: u32,
60 pub pic: String,
61 pub desc: String,
62}
63
64#[derive(Debug, Clone, Deserialize, Serialize)]
66pub struct PublicNoteAuthor {
67 pub mid: u64,
68 pub name: String,
69 pub face: String,
70 pub level: u8,
71 pub vip_info: serde_json::Value,
72 pub pendant: serde_json::Value,
73}
74
75#[derive(Debug, Clone, Deserialize, Serialize)]
77pub struct PublicNoteInfoData {
78 pub cvid: u64,
79 pub note_id: u64,
80 pub title: String,
81 pub summary: String,
82 pub content: String,
83 pub cid_count: u32,
84 pub pub_status: u8,
85 pub tags: Vec<PrivateNoteTag>,
86 pub arc: PublicNoteArc,
87 pub author: PublicNoteAuthor,
88 pub forbid_note_entrance: bool,
89}
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94 use crate::ids::{Aid, Cvid, NoteId};
95 use crate::note::{NoteIsForbidParams, NotePrivateInfoParams, NotePublicInfoParams};
96 use crate::probe::contract::HttpMethod;
97 use crate::probe::endpoint_contract::EndpointContract;
98 use crate::{ApiEnvelope, BpiClient, BpiError, BpiResult};
99 use tracing::info;
100
101 const TEST_AID: u64 = 338_677_252;
102 const TEST_PRIVATE_AID: u64 = 676_931_260;
103 const TEST_NOTE_ID: u64 = 83_577_722_856_540_160;
104 const TEST_CVID: u64 = 15_160_286;
105
106 fn contract(endpoint: &str) -> BpiResult<EndpointContract> {
107 let bytes = match endpoint {
108 "is-forbid" => {
109 include_bytes!("../../tests/contracts/note/read/is-forbid/contract.json").as_slice()
110 }
111 "private-info" => {
112 include_bytes!("../../tests/contracts/note/read/private-info/contract.json")
113 .as_slice()
114 }
115 "public-info" => {
116 include_bytes!("../../tests/contracts/note/read/public-info/contract.json")
117 .as_slice()
118 }
119 _ => {
120 return Err(BpiError::invalid_parameter(
121 "endpoint",
122 "unknown note info contract",
123 ));
124 }
125 };
126
127 EndpointContract::from_slice(bytes)
128 }
129
130 #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
131 #[tokio::test]
132 async fn test_note_is_forbid() {
133 let bpi = BpiClient::new().expect("client should build");
134 let resp = bpi
135 .note()
136 .is_forbid(NoteIsForbidParams::new(
137 Aid::new(TEST_AID).expect("test aid should be valid"),
138 ))
139 .await;
140
141 info!("{:?}", resp);
142 assert!(resp.is_ok());
143
144 let data = resp.unwrap();
145 info!("forbid_note_entrance: {}", data.forbid_note_entrance);
146 }
147
148 #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
149 #[tokio::test]
150 async fn test_note_get_private_info() {
151 let bpi = BpiClient::new().expect("client should build");
152 let resp = bpi
153 .note()
154 .private_info(NotePrivateInfoParams::new(
155 Aid::new(TEST_PRIVATE_AID).expect("test aid should be valid"),
156 NoteId::new(TEST_NOTE_ID).expect("test note id should be valid"),
157 ))
158 .await;
159
160 info!("{:?}", resp);
161 assert!(resp.is_ok());
162
163 let data = resp.unwrap();
164 info!("note title: {}", data.title);
165 info!("note content: {}", data.content);
166 }
167
168 #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
169 #[tokio::test]
170 async fn test_note_get_public_info() {
171 let bpi = BpiClient::new().expect("client should build");
172 let resp = bpi
173 .note()
174 .public_info(NotePublicInfoParams::new(
175 Cvid::new(TEST_CVID).expect("test cvid should be valid"),
176 ))
177 .await;
178
179 info!("{:?}", resp);
180 assert!(resp.is_ok());
181
182 let data = resp.unwrap();
183 info!("note title: {}", data.title);
184 info!("note content: {}", data.content);
185 info!("author name: {}", data.author.name);
186 }
187
188 #[test]
189 fn note_is_forbid_params_serializes_aid() -> Result<(), BpiError> {
190 let params = NoteIsForbidParams::new(Aid::new(TEST_AID)?);
191
192 assert_eq!(params.query_pairs(), vec![("aid", TEST_AID.to_string())]);
193 Ok(())
194 }
195
196 #[test]
197 fn note_private_info_params_serializes_required_query() -> Result<(), BpiError> {
198 let params =
199 NotePrivateInfoParams::new(Aid::new(TEST_PRIVATE_AID)?, NoteId::new(TEST_NOTE_ID)?);
200
201 assert_eq!(
202 params.query_pairs(),
203 vec![
204 ("oid", TEST_PRIVATE_AID.to_string()),
205 ("oid_type", "0".to_string()),
206 ("note_id", TEST_NOTE_ID.to_string()),
207 ]
208 );
209 Ok(())
210 }
211
212 #[test]
213 fn cvid_rejects_zero() {
214 let err = Cvid::new(0).unwrap_err();
215
216 assert!(matches!(
217 err,
218 BpiError::InvalidParameter { field: "cvid", .. }
219 ));
220 }
221
222 #[test]
223 fn note_info_contracts_match_endpoint_requests() -> BpiResult<()> {
224 let is_forbid = contract("is-forbid")?;
225 assert_eq!(is_forbid.name, "note.is_forbid");
226 assert_eq!(is_forbid.request.method, HttpMethod::Get);
227 assert_eq!(
228 is_forbid.request.url.as_str(),
229 "https://api.bilibili.com/x/note/is_forbid"
230 );
231 assert_eq!(
232 is_forbid.request.query.get("aid").map(String::as_str),
233 Some("338677252")
234 );
235 assert_eq!(
236 is_forbid.cases[0].response.rust_model.as_deref(),
237 Some("NoteIsForbidData")
238 );
239
240 let private_info = contract("private-info")?;
241 assert_eq!(private_info.name, "note.private_info");
242 assert_eq!(
243 private_info.request.url.as_str(),
244 "https://api.bilibili.com/x/note/info"
245 );
246 assert_eq!(
247 private_info
248 .request
249 .query
250 .get("note_id")
251 .map(String::as_str),
252 Some("83577722856540160")
253 );
254 assert_eq!(private_info.cases[0].response.api_code, Some(-101));
255 assert_eq!(private_info.cases[1].response.api_code, Some(79511));
256 assert_eq!(
257 private_info.cases[2].response.rust_model.as_deref(),
258 Some("PrivateNoteInfoData")
259 );
260
261 let public_info = contract("public-info")?;
262 assert_eq!(public_info.name, "note.public_info");
263 assert_eq!(
264 public_info.request.url.as_str(),
265 "https://api.bilibili.com/x/note/publish/info"
266 );
267 assert_eq!(
268 public_info.request.query.get("cvid").map(String::as_str),
269 Some("15160286")
270 );
271 assert_eq!(
272 public_info.cases[0].response.rust_model.as_deref(),
273 Some("PublicNoteInfoData")
274 );
275 Ok(())
276 }
277
278 #[test]
279 fn note_info_response_fixtures_parse_declared_models() -> BpiResult<()> {
280 let is_forbid = ApiEnvelope::<NoteIsForbidData>::from_slice(include_bytes!(
281 "../../tests/contracts/note/read/is-forbid/responses/success.json"
282 ))?
283 .into_payload()?;
284 assert!(!is_forbid.forbid_note_entrance);
285
286 let err = ApiEnvelope::<serde_json::Value>::from_slice(include_bytes!(
287 "../../tests/contracts/note/read/private-info/responses/anonymous.requires_login.json"
288 ))
289 .and_then(ApiEnvelope::ensure_success)
290 .unwrap_err();
291 assert!(err.requires_login());
292
293 let not_owner: serde_json::Value = serde_json::from_slice(include_bytes!(
294 "../../tests/contracts/note/read/private-info/responses/normal.not_owner.json"
295 ))?;
296 assert_eq!(not_owner["code"], 79511);
297
298 let private_info = ApiEnvelope::<PrivateNoteInfoData>::from_slice(include_bytes!(
299 "../../tests/contracts/note/read/private-info/responses/vip.success.json"
300 ))?
301 .into_payload()?;
302 assert_eq!(private_info.title, "sanitized private note title");
303
304 let public_info = ApiEnvelope::<PublicNoteInfoData>::from_slice(include_bytes!(
305 "../../tests/contracts/note/read/public-info/responses/success.json"
306 ))?
307 .into_payload()?;
308 assert_eq!(public_info.cvid, TEST_CVID);
309 assert_eq!(public_info.author.name, "sanitized author");
310 Ok(())
311 }
312
313 fn local_probe_body(endpoint: &str, profile: &str) -> Option<serde_json::Value> {
314 let path = format!("target/bpi-probe-runs/note/read/{endpoint}/{profile}.response.json");
315 let bytes = std::fs::read(path).ok()?;
316 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
317 value
318 .get("response")
319 .and_then(|response| response.get("body"))
320 .cloned()
321 }
322
323 #[test]
324 fn note_info_models_match_local_probe_outputs_when_available() -> BpiResult<()> {
325 for profile in ["anonymous", "normal", "vip"] {
326 let Some(body) = local_probe_body("is-forbid", profile) else {
327 continue;
328 };
329 serde_json::from_value::<ApiEnvelope<NoteIsForbidData>>(body)?.into_payload()?;
330 }
331
332 for profile in ["anonymous", "normal", "vip"] {
333 let Some(body) = local_probe_body("private-info", profile) else {
334 continue;
335 };
336 match profile {
337 "anonymous" => {
338 let err = serde_json::from_value::<ApiEnvelope<serde_json::Value>>(body)?
339 .ensure_success()
340 .unwrap_err();
341 assert!(err.requires_login());
342 }
343 "normal" => {
344 let value: serde_json::Value = serde_json::from_value(body)?;
345 assert_eq!(value["code"], 79511);
346 }
347 "vip" => {
348 serde_json::from_value::<ApiEnvelope<PrivateNoteInfoData>>(body)?
349 .into_payload()?;
350 }
351 _ => unreachable!(),
352 }
353 }
354
355 for profile in ["anonymous", "normal", "vip"] {
356 let Some(body) = local_probe_body("public-info", profile) else {
357 continue;
358 };
359 serde_json::from_value::<ApiEnvelope<PublicNoteInfoData>>(body)?.into_payload()?;
360 }
361 Ok(())
362 }
363}