Skip to main content

bpi_rs/dynamic/
nav.rs

1use serde::{Deserialize, Serialize};
2
3use crate::dynamic::serde_utils::deserialize_u64_from_string_or_number;
4
5// --- 导航栏动态 API 结构体 ---
6
7/// 导航栏动态列表项的 UP 主信息
8#[derive(Debug, Clone, Deserialize, Serialize)]
9pub struct DynamicNavAuthor {
10    /// UP 主头像 URL
11    pub face: String,
12    /// UP 主 mid (UID)
13    #[serde(deserialize_with = "deserialize_u64_from_string_or_number")]
14    pub mid: u64,
15    /// UP 主昵称
16    pub name: String,
17}
18
19/// 导航栏动态列表项
20#[derive(Debug, Clone, Deserialize, Serialize)]
21pub struct DynamicNavItem {
22    /// UP 主信息
23    pub author: DynamicNavAuthor,
24    /// 封面 URL
25    pub cover: String,
26    /// 动态 ID 字符串
27    pub id_str: String,
28    /// 发布时间(文字表述的相对时间)
29    pub pub_time: String,
30    /// 关联 ID,视频即 aid
31    #[serde(deserialize_with = "deserialize_u64_from_string_or_number")]
32    pub rid: u64,
33    /// 标题
34    pub title: String,
35    /// 动态类型,8 表示视频
36    #[serde(rename = "type")]
37    pub type_num: u8,
38    /// 是否可见
39    pub visible: bool,
40}
41
42/// 导航栏动态列表响应数据
43#[derive(Debug, Clone, Deserialize, Serialize)]
44pub struct DynamicNavData {
45    /// 是否有更多数据
46    pub has_more: bool,
47    /// 动态数据数组
48    pub items: Vec<DynamicNavItem>,
49    /// 偏移量,用于翻页
50    pub offset: String,
51    /// 更新基线,用于获取新动态
52    pub update_baseline: String,
53    /// 本次获取到的新动态条数
54    #[serde(deserialize_with = "deserialize_u64_from_string_or_number")]
55    pub update_num: u64,
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61    use crate::dynamic::params::DynamicNavFeedParams;
62    use crate::probe::contract::HttpMethod;
63    use crate::probe::endpoint_contract::EndpointContract;
64    use crate::{ApiEnvelope, BpiClient, BpiError, BpiResult};
65    use std::collections::BTreeMap;
66    use tracing::info;
67
68    fn contract() -> BpiResult<EndpointContract> {
69        EndpointContract::from_slice(include_bytes!(
70            "../../tests/contracts/dynamic/feed/nav/contract.json"
71        ))
72    }
73
74    fn query_map(query: Vec<(&'static str, String)>) -> BTreeMap<String, String> {
75        query
76            .into_iter()
77            .map(|(key, value)| (key.to_string(), value))
78            .collect()
79    }
80
81    #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
82    #[tokio::test]
83    async fn test_get_dynamic_nav_feed() -> Result<(), BpiError> {
84        let bpi = BpiClient::new().expect("client should build");
85        let data = bpi.dynamic().nav_feed(DynamicNavFeedParams::new()).await?;
86
87        info!("获取到 {} 条动态", data.items.len());
88        info!("第一条动态ID: {}", data.items[0].id_str);
89
90        assert!(!data.items.is_empty());
91
92        Ok(())
93    }
94
95    #[test]
96    fn dynamic_nav_contract_matches_endpoint_request() -> BpiResult<()> {
97        let contract = contract()?;
98
99        assert_eq!(contract.name, "dynamic.feed_nav");
100        assert_eq!(contract.request.method, HttpMethod::Get);
101        assert_eq!(
102            contract.request.url.as_str(),
103            "https://api.bilibili.com/x/polymer/web-dynamic/v1/feed/nav"
104        );
105        assert_eq!(
106            contract.request.query,
107            query_map(DynamicNavFeedParams::new().query_pairs())
108        );
109        assert_eq!(contract.cases.len(), 3);
110        assert_eq!(
111            contract.cases[0].response.error.as_deref(),
112            Some("requires_login")
113        );
114        assert_eq!(
115            contract.cases[1].response.rust_model.as_deref(),
116            Some("DynamicNavData")
117        );
118        Ok(())
119    }
120
121    #[test]
122    fn dynamic_nav_response_fixtures_parse_declared_model() -> BpiResult<()> {
123        for bytes in [
124            include_bytes!("../../tests/contracts/dynamic/feed/nav/responses/normal.success.json")
125                .as_slice(),
126            include_bytes!("../../tests/contracts/dynamic/feed/nav/responses/vip.success.json")
127                .as_slice(),
128        ] {
129            let payload = ApiEnvelope::<DynamicNavData>::from_slice(bytes)?.into_payload()?;
130            assert_eq!(payload.items.len(), 1);
131            assert_eq!(payload.items[0].author.mid, 1);
132            assert_eq!(payload.update_num, 0);
133        }
134        Ok(())
135    }
136
137    #[test]
138    fn dynamic_nav_anonymous_fixture_records_login_error() -> BpiResult<()> {
139        let err = ApiEnvelope::<serde_json::Value>::from_slice(include_bytes!(
140            "../../tests/contracts/dynamic/feed/nav/responses/anonymous.requires_login.json"
141        ))?
142        .ensure_success()
143        .unwrap_err();
144
145        assert_eq!(err.code(), Some(-101));
146        Ok(())
147    }
148
149    fn local_probe_body(profile: &str) -> Option<serde_json::Value> {
150        let path =
151            format!("target/bpi-probe-runs/dynamic/feed-readonly/nav-feed/{profile}.response.json");
152        let bytes = std::fs::read(path).ok()?;
153        let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
154        value
155            .get("response")
156            .and_then(|response| response.get("body"))
157            .cloned()
158    }
159
160    #[test]
161    fn dynamic_nav_model_matches_local_probe_outputs_when_available() -> BpiResult<()> {
162        for profile in ["normal", "vip"] {
163            let Some(body) = local_probe_body(profile) else {
164                continue;
165            };
166            let payload =
167                serde_json::from_value::<ApiEnvelope<DynamicNavData>>(body)?.into_payload()?;
168            assert!(!payload.items.is_empty());
169        }
170
171        if let Some(body) = local_probe_body("anonymous") {
172            let err = serde_json::from_value::<ApiEnvelope<serde_json::Value>>(body)?
173                .ensure_success()
174                .unwrap_err();
175            assert_eq!(err.code(), Some(-101));
176        }
177        Ok(())
178    }
179}