Skip to main content

bpi_rs/dynamic/
all.rs

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/// 检测新动态响应数据
36#[derive(Debug, Clone, Deserialize)]
37pub struct DynamicUpdateData {
38    /// 新动态的数量
39    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}