1use serde::{Deserialize, Serialize};
2
3use crate::dynamic::serde_utils::deserialize_u64_from_string_or_number;
4
5#[derive(Debug, Clone, Default, Serialize, Deserialize)]
6pub struct DynamicAllData {
7 pub has_more: bool,
8 pub items: Vec<DynamicItem>,
9 pub offset: String,
10 pub update_baseline: String,
11 #[serde(deserialize_with = "deserialize_u64_from_string_or_number")]
12 pub update_num: u64,
13}
14
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
16pub struct DynamicItem {
17 pub basic: Basic,
18 pub id_str: String,
19 pub modules: serde_json::Value,
20 #[serde(rename = "type")]
21 pub type_field: String,
22 pub visible: bool,
23}
24
25#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
26pub struct Basic {
27 pub comment_id_str: String,
28 pub comment_type: i64,
29 pub like_icon: serde_json::Value,
30 pub rid_str: String,
31 pub is_only_fans: Option<bool>,
32 pub jump_url: Option<String>,
33}
34
35#[derive(Debug, Clone, Deserialize)]
37pub struct DynamicUpdateData {
38 pub update_num: u64,
40}
41
42#[cfg(test)]
43mod tests {
44 use super::*;
45 use crate::dynamic::params::{DynamicAllParams, DynamicCheckNewParams};
46 use crate::probe::contract::HttpMethod;
47 use crate::probe::endpoint_contract::EndpointContract;
48 use crate::{ApiEnvelope, BpiClient, BpiError, BpiResult};
49 use std::collections::BTreeMap;
50 use tracing::info;
51
52 fn contract(endpoint: &str) -> BpiResult<EndpointContract> {
53 let bytes = match endpoint {
54 "all" => {
55 include_bytes!("../../tests/contracts/dynamic/feed/all/contract.json").as_slice()
56 }
57 "check-new" => {
58 include_bytes!("../../tests/contracts/dynamic/feed/check-new/contract.json")
59 .as_slice()
60 }
61 _ => unreachable!("unknown dynamic feed endpoint"),
62 };
63
64 EndpointContract::from_slice(bytes)
65 }
66
67 fn query_map(query: Vec<(&'static str, String)>) -> BTreeMap<String, String> {
68 query
69 .into_iter()
70 .map(|(key, value)| (key.to_string(), value))
71 .collect()
72 }
73
74 #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
75 #[tokio::test]
76 async fn test_dynamic_get_all() -> Result<(), BpiError> {
77 let bpi = BpiClient::new().expect("client should build");
78 let data = bpi.dynamic().all(DynamicAllParams::new()).await?;
79
80 info!("成功获取 {} 条动态", data.items.len());
81
82 Ok(())
83 }
84
85 #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
86 #[tokio::test]
87 async fn test_dynamic_check_new() -> Result<(), BpiError> {
88 let bpi = BpiClient::new().expect("client should build");
89 let update_baseline = "0";
90 let data = bpi
91 .dynamic()
92 .check_new(DynamicCheckNewParams::new(update_baseline)?)
93 .await?;
94
95 info!("成功检测到 {} 条新动态", data.update_num);
96
97 Ok(())
98 }
99
100 #[test]
101 fn dynamic_feed_contracts_match_endpoint_requests() -> BpiResult<()> {
102 let all = contract("all")?;
103 assert_eq!(all.name, "dynamic.feed_all");
104 assert_eq!(all.request.method, HttpMethod::Get);
105 assert_eq!(
106 all.request.url.as_str(),
107 "https://api.bilibili.com/x/polymer/web-dynamic/v1/feed/all"
108 );
109 assert_eq!(
110 all.request.query,
111 query_map(DynamicAllParams::new().query_pairs())
112 );
113 assert_eq!(all.cases.len(), 3);
114 assert_eq!(
115 all.cases[0].response.error.as_deref(),
116 Some("requires_login")
117 );
118
119 let check_new = contract("check-new")?;
120 assert_eq!(check_new.name, "dynamic.feed_all_update");
121 assert_eq!(check_new.request.method, HttpMethod::Get);
122 assert_eq!(
123 check_new.request.url.as_str(),
124 "https://api.bilibili.com/x/polymer/web-dynamic/v1/feed/all/update"
125 );
126 assert_eq!(
127 check_new.request.query,
128 query_map(DynamicCheckNewParams::new("0")?.query_pairs())
129 );
130 assert_eq!(check_new.cases.len(), 3);
131 assert_eq!(
132 check_new.cases[0].response.error.as_deref(),
133 Some("requires_login")
134 );
135 Ok(())
136 }
137
138 #[test]
139 fn dynamic_feed_response_fixtures_parse_declared_models() -> BpiResult<()> {
140 for bytes in [
141 include_bytes!("../../tests/contracts/dynamic/feed/all/responses/normal.success.json")
142 .as_slice(),
143 include_bytes!("../../tests/contracts/dynamic/feed/all/responses/vip.success.json")
144 .as_slice(),
145 ] {
146 let payload = ApiEnvelope::<DynamicAllData>::from_slice(bytes)?.into_payload()?;
147 assert_eq!(payload.items.len(), 1);
148 }
149
150 for bytes in [
151 include_bytes!(
152 "../../tests/contracts/dynamic/feed/check-new/responses/normal.success.json"
153 )
154 .as_slice(),
155 include_bytes!(
156 "../../tests/contracts/dynamic/feed/check-new/responses/vip.success.json"
157 )
158 .as_slice(),
159 ] {
160 let payload = ApiEnvelope::<DynamicUpdateData>::from_slice(bytes)?.into_payload()?;
161 assert_eq!(payload.update_num, 0);
162 }
163 Ok(())
164 }
165
166 #[test]
167 fn dynamic_feed_anonymous_fixtures_record_login_errors() -> BpiResult<()> {
168 for bytes in [
169 include_bytes!(
170 "../../tests/contracts/dynamic/feed/all/responses/anonymous.requires_login.json"
171 )
172 .as_slice(),
173 include_bytes!(
174 "../../tests/contracts/dynamic/feed/check-new/responses/anonymous.requires_login.json"
175 )
176 .as_slice(),
177 ] {
178 let err = ApiEnvelope::<serde_json::Value>::from_slice(bytes)?
179 .ensure_success()
180 .unwrap_err();
181 assert_eq!(err.code(), Some(-101));
182 }
183 Ok(())
184 }
185
186 fn local_probe_body(endpoint: &str, profile: &str) -> Option<serde_json::Value> {
187 let path = format!(
188 "target/bpi-probe-runs/dynamic/feed-readonly/{endpoint}/{profile}.response.json"
189 );
190 let bytes = std::fs::read(path).ok()?;
191 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
192 value
193 .get("response")
194 .and_then(|response| response.get("body"))
195 .cloned()
196 }
197
198 #[test]
199 fn dynamic_feed_models_match_local_probe_outputs_when_available() -> BpiResult<()> {
200 for profile in ["normal", "vip"] {
201 if let Some(body) = local_probe_body("all", profile) {
202 let payload =
203 serde_json::from_value::<ApiEnvelope<DynamicAllData>>(body)?.into_payload()?;
204 assert!(!payload.items.is_empty());
205 }
206
207 if let Some(body) = local_probe_body("check-new", profile) {
208 let payload = serde_json::from_value::<ApiEnvelope<DynamicUpdateData>>(body)?
209 .into_payload()?;
210 let _ = payload.update_num;
211 }
212 }
213
214 for endpoint in ["all", "check-new"] {
215 if let Some(body) = local_probe_body(endpoint, "anonymous") {
216 let err = serde_json::from_value::<ApiEnvelope<serde_json::Value>>(body)?
217 .ensure_success()
218 .unwrap_err();
219 assert_eq!(err.code(), Some(-101));
220 }
221 }
222 Ok(())
223 }
224}