1use crate::article::models::ArticleStats;
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct ArticleInfoData {
11 pub like: i32,
13 pub attention: bool,
15 pub favorite: bool,
17 pub coin: i32,
19 pub stats: ArticleStats,
21 pub title: String,
23 pub banner_url: String,
25 pub mid: i64,
27 pub author_name: String,
29 pub is_author: bool,
31 pub image_urls: Vec<String>,
33 pub origin_image_urls: Vec<String>,
35 pub shareable: bool,
37 pub show_later_watch: bool,
39 pub show_small_window: bool,
41 pub in_list: bool,
43 pub pre: i64,
45 pub next: i64,
47 pub share_channels: Vec<ShareChannel>,
49 pub r#type: i32,
51 #[serde(default)]
53 pub video_url: String,
54 #[serde(default)]
56 pub location: String,
57 #[serde(default)]
59 pub disable_share: bool,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct ShareChannel {
65 pub name: String,
67 pub picture: String,
69 pub share_channel: String,
71}
72
73#[cfg(test)]
74mod tests {
75 use super::*;
76 use crate::article::params::ArticleInfoParams;
77 use crate::probe::contract::HttpMethod;
78 use crate::probe::endpoint_contract::EndpointContract;
79 use crate::{ApiEnvelope, BpiClient, BpiError, BpiResult};
80
81 const TEST_CVID: i64 = 2;
82
83 fn contract() -> BpiResult<EndpointContract> {
84 EndpointContract::from_slice(include_bytes!(
85 "../../tests/contracts/article/info/contract.json"
86 ))
87 }
88
89 #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
90 #[tokio::test]
91 async fn test_article_info() -> Result<(), Box<BpiError>> {
92 let bpi = BpiClient::new().expect("client should build");
93
94 let params = ArticleInfoParams::new(TEST_CVID)?;
95
96 let data = bpi.article().info(params).await?;
97 assert!(!data.title.is_empty());
98 assert!(!data.author_name.is_empty());
99 assert!(data.mid > 0);
100
101 Ok(())
102 }
103
104 #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
105 #[tokio::test]
106 async fn test_article_stats() -> Result<(), Box<BpiError>> {
107 let bpi = BpiClient::new().expect("client should build");
108
109 let params = ArticleInfoParams::new(TEST_CVID)?;
110 let data = bpi.article().info(params).await?;
111 let stats = &data.stats;
112 assert!(stats.view >= 0);
113 assert!(stats.favorite >= 0);
114 assert!(stats.like >= 0);
115 assert!(stats.reply >= 0);
116
117 Ok(())
118 }
119
120 #[test]
121 fn article_info_contract_matches_endpoint_request() -> BpiResult<()> {
122 let contract = contract()?;
123 let params = ArticleInfoParams::new(TEST_CVID)?;
124
125 assert_eq!(contract.name, "article.info");
126 assert_eq!(contract.request.method, HttpMethod::Get);
127 assert_eq!(
128 contract.request.url.as_str(),
129 "https://api.bilibili.com/x/article/viewinfo"
130 );
131 assert_eq!(
132 contract.request.query.get("id").map(String::as_str),
133 Some("2")
134 );
135 assert_eq!(params.query_pairs(), vec![("id", "2".to_string())]);
136 assert_eq!(contract.cases.len(), 3);
137 assert_eq!(
138 contract.cases[0].response.rust_model.as_deref(),
139 Some("ArticleInfoData")
140 );
141 Ok(())
142 }
143
144 #[test]
145 fn article_info_response_fixtures_parse_declared_model() -> BpiResult<()> {
146 for bytes in [
147 include_bytes!("../../tests/contracts/article/info/responses/anonymous.success.json")
148 .as_slice(),
149 include_bytes!("../../tests/contracts/article/info/responses/normal.success.json")
150 .as_slice(),
151 include_bytes!("../../tests/contracts/article/info/responses/vip.success.json")
152 .as_slice(),
153 ] {
154 let payload = ApiEnvelope::<ArticleInfoData>::from_slice(bytes)?.into_payload()?;
155
156 assert!(!payload.title.is_empty());
157 assert!(!payload.author_name.is_empty());
158 assert!(payload.mid > 0);
159 }
160 Ok(())
161 }
162
163 fn local_probe_body(profile: &str) -> Option<serde_json::Value> {
164 let path = format!("target/bpi-probe-runs/article/read/info/{profile}.response.json");
165 let bytes = std::fs::read(path).ok()?;
166 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
167 value
168 .get("response")
169 .and_then(|response| response.get("body"))
170 .cloned()
171 }
172
173 #[test]
174 fn article_info_model_matches_local_probe_outputs_when_available() -> BpiResult<()> {
175 for profile in ["anonymous", "normal", "vip"] {
176 let Some(body) = local_probe_body(profile) else {
177 continue;
178 };
179 let payload =
180 serde_json::from_value::<ApiEnvelope<ArticleInfoData>>(body)?.into_payload()?;
181
182 assert!(payload.mid > 0);
183 }
184 Ok(())
185 }
186}