1use crate::{BpiError, BpiResult};
6use serde::{Deserialize, Serialize};
7
8use super::types::{
9 Comment, Config,
11 Control,
12 Cursor,
13 PageInfo,
14 Top,
15 Upper,
16};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub struct CommentTarget {
21 r#type: i32,
22 oid: i64,
23}
24
25impl CommentTarget {
26 pub fn new(r#type: i32, oid: i64) -> BpiResult<Self> {
27 if r#type <= 0 {
28 return Err(BpiError::invalid_parameter(
29 "type",
30 "value must be greater than zero",
31 ));
32 }
33 if oid <= 0 {
34 return Err(BpiError::invalid_parameter(
35 "oid",
36 "value must be greater than zero",
37 ));
38 }
39 Ok(Self { r#type, oid })
40 }
41
42 fn query_pairs(&self) -> Vec<(&'static str, String)> {
43 vec![
44 ("type", self.r#type.to_string()),
45 ("oid", self.oid.to_string()),
46 ]
47 }
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum CommentSort {
53 Time,
54 Like,
55 Replies,
56}
57
58impl CommentSort {
59 fn as_i32(self) -> i32 {
60 match self {
61 Self::Time => 0,
62 Self::Like => 1,
63 Self::Replies => 2,
64 }
65 }
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub struct CommentListParams {
71 target: CommentTarget,
72 page: Option<u32>,
73 page_size: Option<u32>,
74 sort: Option<CommentSort>,
75 nohot: Option<bool>,
76}
77
78impl CommentListParams {
79 pub fn new(target: CommentTarget) -> Self {
80 Self {
81 target,
82 page: None,
83 page_size: None,
84 sort: None,
85 nohot: None,
86 }
87 }
88
89 pub fn with_page(mut self, page: u32) -> BpiResult<Self> {
90 self.page = Some(validate_positive("pn", page)?);
91 Ok(self)
92 }
93
94 pub fn with_page_size(mut self, page_size: u32) -> BpiResult<Self> {
95 let page_size = validate_positive("ps", page_size)?;
96 if page_size > 20 {
97 return Err(BpiError::invalid_parameter(
98 "ps",
99 "value must be less than or equal to 20",
100 ));
101 }
102 self.page_size = Some(page_size);
103 Ok(self)
104 }
105
106 pub fn with_sort(mut self, sort: CommentSort) -> Self {
107 self.sort = Some(sort);
108 self
109 }
110
111 pub fn without_hot(mut self, nohot: bool) -> Self {
112 self.nohot = Some(nohot);
113 self
114 }
115
116 pub(crate) fn query_pairs(&self) -> Vec<(&'static str, String)> {
117 let mut params = self.target.query_pairs();
118 if let Some(page) = self.page {
119 params.push(("pn", page.to_string()));
120 }
121 if let Some(page_size) = self.page_size {
122 params.push(("ps", page_size.to_string()));
123 }
124 if let Some(sort) = self.sort {
125 params.push(("sort", sort.as_i32().to_string()));
126 }
127 if let Some(nohot) = self.nohot {
128 params.push(("nohot", i32::from(nohot).to_string()));
129 }
130 params
131 }
132}
133
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub struct CommentRepliesParams {
137 target: CommentTarget,
138 root: i64,
139 page: Option<u32>,
140 page_size: Option<u32>,
141}
142
143impl CommentRepliesParams {
144 pub fn new(target: CommentTarget, root: i64) -> BpiResult<Self> {
145 if root <= 0 {
146 return Err(BpiError::invalid_parameter(
147 "root",
148 "value must be greater than zero",
149 ));
150 }
151 Ok(Self {
152 target,
153 root,
154 page: None,
155 page_size: None,
156 })
157 }
158
159 pub fn with_page(mut self, page: u32) -> BpiResult<Self> {
160 self.page = Some(validate_positive("pn", page)?);
161 Ok(self)
162 }
163
164 pub fn with_page_size(mut self, page_size: u32) -> BpiResult<Self> {
165 self.page_size = Some(validate_positive("ps", page_size)?);
166 Ok(self)
167 }
168
169 pub(crate) fn query_pairs(&self) -> Vec<(&'static str, String)> {
170 let mut params = self.target.query_pairs();
171 params.push(("root", self.root.to_string()));
172 if let Some(page) = self.page {
173 params.push(("pn", page.to_string()));
174 }
175 if let Some(page_size) = self.page_size {
176 params.push(("ps", page_size.to_string()));
177 }
178 params
179 }
180}
181
182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184pub struct CommentHotParams {
185 target: CommentTarget,
186 root: i64,
187 page: Option<u32>,
188 page_size: Option<u32>,
189}
190
191impl CommentHotParams {
192 pub fn new(target: CommentTarget, root: i64) -> BpiResult<Self> {
193 if root <= 0 {
194 return Err(BpiError::invalid_parameter(
195 "root",
196 "value must be greater than zero",
197 ));
198 }
199 Ok(Self {
200 target,
201 root,
202 page: None,
203 page_size: None,
204 })
205 }
206
207 pub fn with_page(mut self, page: u32) -> BpiResult<Self> {
208 self.page = Some(validate_positive("pn", page)?);
209 Ok(self)
210 }
211
212 pub fn with_page_size(mut self, page_size: u32) -> BpiResult<Self> {
213 self.page_size = Some(validate_positive("ps", page_size)?);
214 Ok(self)
215 }
216
217 pub(crate) fn query_pairs(&self) -> Vec<(&'static str, String)> {
218 let mut params = self.target.query_pairs();
219 params.push(("root", self.root.to_string()));
220 if let Some(page) = self.page {
221 params.push(("pn", page.to_string()));
222 }
223 if let Some(page_size) = self.page_size {
224 params.push(("ps", page_size.to_string()));
225 }
226 params
227 }
228}
229
230#[derive(Debug, Clone, Copy, PartialEq, Eq)]
232pub struct CommentCountParams {
233 target: CommentTarget,
234}
235
236impl CommentCountParams {
237 pub fn new(target: CommentTarget) -> Self {
238 Self { target }
239 }
240
241 pub(crate) fn query_pairs(&self) -> Vec<(&'static str, String)> {
242 self.target.query_pairs()
243 }
244}
245
246#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct CommentListData {
248 pub page: Option<PageInfo>,
249 pub cursor: Option<Cursor>, pub replies: Option<Vec<Comment>>, pub top: Option<Top>, pub top_replies: Option<Vec<Comment>>,
253 pub effects: Option<serde_json::Value>,
254 pub assist: Option<u64>, pub blacklist: Option<u64>, pub vote: Option<u64>, pub config: Option<Config>, pub upper: Option<Upper>, pub control: Option<Control>, pub note: Option<u32>,
262 pub cm_info: Option<serde_json::Value>, }
273
274#[derive(Debug, Clone, Serialize, Deserialize)]
276pub struct Notice {
277 pub content: Option<String>,
278 pub id: Option<u64>,
279 pub link: Option<String>,
280 pub title: Option<String>,
281}
282
283#[derive(Debug, Clone, Deserialize, Serialize)]
284pub struct HotCommentData {
285 pub page: HotCommentPage,
286 pub replies: Vec<Comment>, }
288
289#[derive(Debug, Clone, Deserialize, Serialize)]
290pub struct HotCommentPage {
291 pub acount: i64, pub count: i64, pub num: i32, pub size: i32, }
296
297#[derive(Debug, Clone, Deserialize, Serialize)]
298pub struct CountData {
299 pub count: u64,
300}
301
302fn validate_positive(field: &'static str, value: u32) -> BpiResult<u32> {
303 if value == 0 {
304 return Err(BpiError::invalid_parameter(
305 field,
306 "value must be greater than zero",
307 ));
308 }
309 Ok(value)
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315 use crate::ApiEnvelope;
316 use crate::BpiClient;
317 use crate::probe::contract::HttpMethod;
318 use crate::probe::endpoint_contract::EndpointContract;
319 use std::collections::BTreeMap;
320 use tracing::info;
321
322 const TEST_TYPE: i32 = 1;
323 const TEST_OID: i64 = 23199;
324 const TEST_ROOT_RPID: i64 = 2554491176;
325
326 fn target() -> BpiResult<CommentTarget> {
327 CommentTarget::new(TEST_TYPE, TEST_OID)
328 }
329
330 fn contract(name: &str) -> BpiResult<EndpointContract> {
331 let bytes = match name {
332 "list" => {
333 include_bytes!("../../tests/contracts/comment/read/list/contract.json").as_slice()
334 }
335 "replies" => include_bytes!("../../tests/contracts/comment/read/replies/contract.json")
336 .as_slice(),
337 "hot" => {
338 include_bytes!("../../tests/contracts/comment/read/hot/contract.json").as_slice()
339 }
340 "count" => {
341 include_bytes!("../../tests/contracts/comment/read/count/contract.json").as_slice()
342 }
343 _ => unreachable!("unknown comment read contract"),
344 };
345 EndpointContract::from_slice(bytes)
346 }
347
348 fn query_map(params: Vec<(&'static str, String)>) -> BTreeMap<String, String> {
349 params
350 .into_iter()
351 .map(|(key, value)| (key.to_string(), value))
352 .collect()
353 }
354
355 #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
356 #[tokio::test]
357 async fn test_comment_list() -> Result<(), Box<BpiError>> {
358 let bpi = BpiClient::new().expect("client should build");
359
360 let result = bpi
361 .comment()
362 .list(
363 CommentListParams::new(CommentTarget::new(TEST_TYPE, TEST_OID)?)
364 .with_page(1)?
365 .with_page_size(5)?
366 .with_sort(CommentSort::Time)
367 .without_hot(false),
368 )
369 .await?;
370 let data = result;
371 info!("总评论数: {}", data.replies.unwrap().len());
372
373 Ok(())
374 }
375
376 #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
377 #[tokio::test]
378 async fn test_comment_replies() -> Result<(), Box<BpiError>> {
379 let bpi = BpiClient::new().expect("client should build");
380
381 let result = bpi
382 .comment()
383 .replies(
384 CommentRepliesParams::new(
385 CommentTarget::new(TEST_TYPE, TEST_OID)?,
386 TEST_ROOT_RPID,
387 )?
388 .with_page(1)?
389 .with_page_size(5)?,
390 )
391 .await?;
392 let data = result;
393 info!("总评论数: {}", data.replies.unwrap().len());
394
395 Ok(())
396 }
397
398 #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
399 #[tokio::test]
400 async fn test_comment_hot() -> Result<(), Box<BpiError>> {
401 let bpi = BpiClient::new().expect("client should build");
402 let root_rpid = 654321;
403
404 let result = bpi
405 .comment()
406 .hot(
407 CommentHotParams::new(CommentTarget::new(TEST_TYPE, TEST_OID)?, root_rpid)?
408 .with_page(1)?
409 .with_page_size(5)?,
410 )
411 .await?;
412 let data = result.ok_or_else(|| BpiError::unsupported_response("missing hot comments"))?;
413
414 info!("热评数量: {}", data.replies.len());
415 for comment in data.replies.iter() {
416 info!("热评内容: {}", comment.content.message);
417 }
418
419 Ok(())
420 }
421
422 #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
423 #[tokio::test]
424 async fn test_comment_count() -> Result<(), Box<BpiError>> {
425 let bpi = BpiClient::new().expect("client should build");
426
427 let result = bpi
428 .comment()
429 .count(CommentCountParams::new(CommentTarget::new(
430 TEST_TYPE, TEST_OID,
431 )?))
432 .await?;
433
434 let data = result;
435 info!("评论总数: {}", data.count);
436
437 Ok(())
438 }
439
440 #[test]
441 fn comment_target_rejects_invalid_identifiers() {
442 let type_err = CommentTarget::new(0, TEST_OID).unwrap_err();
443 assert!(matches!(
444 type_err,
445 BpiError::InvalidParameter { field: "type", .. }
446 ));
447
448 let oid_err = CommentTarget::new(TEST_TYPE, 0).unwrap_err();
449 assert!(matches!(
450 oid_err,
451 BpiError::InvalidParameter { field: "oid", .. }
452 ));
453 }
454
455 #[test]
456 fn comment_list_params_serializes_query() -> BpiResult<()> {
457 let params = CommentListParams::new(target()?)
458 .with_page(1)?
459 .with_page_size(5)?
460 .with_sort(CommentSort::Time)
461 .without_hot(false);
462
463 assert_eq!(
464 params.query_pairs(),
465 vec![
466 ("type", "1".to_string()),
467 ("oid", "23199".to_string()),
468 ("pn", "1".to_string()),
469 ("ps", "5".to_string()),
470 ("sort", "0".to_string()),
471 ("nohot", "0".to_string()),
472 ]
473 );
474 Ok(())
475 }
476
477 #[test]
478 fn comment_list_params_rejects_large_page_size() -> BpiResult<()> {
479 let err = CommentListParams::new(target()?)
480 .with_page_size(21)
481 .unwrap_err();
482
483 assert!(matches!(
484 err,
485 BpiError::InvalidParameter { field: "ps", .. }
486 ));
487 Ok(())
488 }
489
490 #[test]
491 fn comment_replies_params_serializes_query() -> BpiResult<()> {
492 let params = CommentRepliesParams::new(target()?, TEST_ROOT_RPID)?
493 .with_page(1)?
494 .with_page_size(5)?;
495
496 assert_eq!(
497 params.query_pairs(),
498 vec![
499 ("type", "1".to_string()),
500 ("oid", "23199".to_string()),
501 ("root", "2554491176".to_string()),
502 ("pn", "1".to_string()),
503 ("ps", "5".to_string()),
504 ]
505 );
506 Ok(())
507 }
508
509 #[test]
510 fn comment_hot_params_serializes_query() -> BpiResult<()> {
511 let params = CommentHotParams::new(target()?, TEST_ROOT_RPID)?
512 .with_page(1)?
513 .with_page_size(5)?;
514
515 assert_eq!(
516 params.query_pairs(),
517 vec![
518 ("type", "1".to_string()),
519 ("oid", "23199".to_string()),
520 ("root", "2554491176".to_string()),
521 ("pn", "1".to_string()),
522 ("ps", "5".to_string()),
523 ]
524 );
525 Ok(())
526 }
527
528 #[test]
529 fn comment_count_params_serializes_query() -> BpiResult<()> {
530 let params = CommentCountParams::new(target()?);
531
532 assert_eq!(
533 params.query_pairs(),
534 vec![("type", "1".to_string()), ("oid", "23199".to_string())]
535 );
536 Ok(())
537 }
538
539 #[test]
540 fn comment_read_contracts_match_endpoint_requests() -> BpiResult<()> {
541 let list = contract("list")?;
542 let list_params = CommentListParams::new(target()?)
543 .with_page(1)?
544 .with_page_size(5)?
545 .with_sort(CommentSort::Time)
546 .without_hot(false);
547 assert_eq!(list.name, "comment.read.list");
548 assert_eq!(list.request.method, HttpMethod::Get);
549 assert_eq!(
550 list.request.url.as_str(),
551 "https://api.bilibili.com/x/v2/reply"
552 );
553 assert_eq!(query_map(list_params.query_pairs()), list.request.query);
554
555 let replies = contract("replies")?;
556 let replies_params = CommentRepliesParams::new(target()?, TEST_ROOT_RPID)?
557 .with_page(1)?
558 .with_page_size(5)?;
559 assert_eq!(replies.name, "comment.read.replies");
560 assert_eq!(
561 replies.request.url.as_str(),
562 "https://api.bilibili.com/x/v2/reply/reply"
563 );
564 assert_eq!(
565 query_map(replies_params.query_pairs()),
566 replies.request.query
567 );
568
569 let hot = contract("hot")?;
570 let hot_params = CommentHotParams::new(target()?, TEST_ROOT_RPID)?
571 .with_page(1)?
572 .with_page_size(5)?;
573 assert_eq!(hot.name, "comment.read.hot");
574 assert_eq!(
575 hot.request.url.as_str(),
576 "https://api.bilibili.com/x/v2/reply/hot"
577 );
578 assert_eq!(query_map(hot_params.query_pairs()), hot.request.query);
579
580 let count = contract("count")?;
581 let count_params = CommentCountParams::new(target()?);
582 assert_eq!(count.name, "comment.read.count");
583 assert_eq!(
584 count.request.url.as_str(),
585 "https://api.bilibili.com/x/v2/reply/count"
586 );
587 assert_eq!(query_map(count_params.query_pairs()), count.request.query);
588 Ok(())
589 }
590
591 #[test]
592 fn comment_read_response_fixtures_parse_declared_models() -> BpiResult<()> {
593 for bytes in [
594 include_bytes!(
595 "../../tests/contracts/comment/read/list/responses/anonymous.success.json"
596 )
597 .as_slice(),
598 include_bytes!("../../tests/contracts/comment/read/list/responses/normal.success.json")
599 .as_slice(),
600 include_bytes!("../../tests/contracts/comment/read/list/responses/vip.success.json")
601 .as_slice(),
602 include_bytes!(
603 "../../tests/contracts/comment/read/replies/responses/anonymous.success.json"
604 )
605 .as_slice(),
606 include_bytes!(
607 "../../tests/contracts/comment/read/replies/responses/normal.success.json"
608 )
609 .as_slice(),
610 include_bytes!("../../tests/contracts/comment/read/replies/responses/vip.success.json")
611 .as_slice(),
612 ] {
613 let payload = ApiEnvelope::<CommentListData>::from_slice(bytes)?.into_payload()?;
614 assert!(payload.page.is_some());
615 }
616
617 for bytes in [
618 include_bytes!(
619 "../../tests/contracts/comment/read/count/responses/anonymous.success.json"
620 )
621 .as_slice(),
622 include_bytes!(
623 "../../tests/contracts/comment/read/count/responses/normal.success.json"
624 )
625 .as_slice(),
626 include_bytes!("../../tests/contracts/comment/read/count/responses/vip.success.json")
627 .as_slice(),
628 ] {
629 let payload = ApiEnvelope::<CountData>::from_slice(bytes)?.into_payload()?;
630 assert_eq!(payload.count, 10);
631 }
632
633 for bytes in [
634 include_bytes!(
635 "../../tests/contracts/comment/read/hot/responses/anonymous.success.json"
636 )
637 .as_slice(),
638 include_bytes!("../../tests/contracts/comment/read/hot/responses/normal.success.json")
639 .as_slice(),
640 include_bytes!("../../tests/contracts/comment/read/hot/responses/vip.success.json")
641 .as_slice(),
642 ] {
643 let payload =
644 ApiEnvelope::<HotCommentData>::from_slice(bytes)?.into_optional_payload()?;
645 assert!(payload.is_none());
646 }
647 Ok(())
648 }
649
650 fn local_probe_body(endpoint: &str, profile: &str) -> Option<serde_json::Value> {
651 let path = format!("target/bpi-probe-runs/comment/read/{endpoint}/{profile}.response.json");
652 let bytes = std::fs::read(path).ok()?;
653 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
654 value
655 .get("response")
656 .and_then(|response| response.get("body"))
657 .cloned()
658 }
659
660 #[test]
661 fn comment_read_models_match_local_probe_outputs_when_available() -> BpiResult<()> {
662 for profile in ["anonymous", "normal", "vip"] {
663 for endpoint in ["list", "replies"] {
664 let Some(body) = local_probe_body(endpoint, profile) else {
665 continue;
666 };
667 let payload =
668 serde_json::from_value::<ApiEnvelope<CommentListData>>(body)?.into_payload()?;
669 assert!(payload.page.is_some());
670 }
671
672 let Some(count_body) = local_probe_body("count", profile) else {
673 continue;
674 };
675 let count =
676 serde_json::from_value::<ApiEnvelope<CountData>>(count_body)?.into_payload()?;
677 assert_eq!(count.count, 10);
678
679 let Some(hot_body) = local_probe_body("hot", profile) else {
680 continue;
681 };
682 let hot = serde_json::from_value::<ApiEnvelope<HotCommentData>>(hot_body)?
683 .into_optional_payload()?;
684 assert!(hot.is_none());
685 }
686 Ok(())
687 }
688}