1use serde::{Deserialize, Serialize};
2
3use crate::dynamic::serde_utils::deserialize_u64_from_string_or_number;
4
5#[derive(Debug, Clone, Deserialize, Serialize)]
9pub struct DynamicNavAuthor {
10 pub face: String,
12 #[serde(deserialize_with = "deserialize_u64_from_string_or_number")]
14 pub mid: u64,
15 pub name: String,
17}
18
19#[derive(Debug, Clone, Deserialize, Serialize)]
21pub struct DynamicNavItem {
22 pub author: DynamicNavAuthor,
24 pub cover: String,
26 pub id_str: String,
28 pub pub_time: String,
30 #[serde(deserialize_with = "deserialize_u64_from_string_or_number")]
32 pub rid: u64,
33 pub title: String,
35 #[serde(rename = "type")]
37 pub type_num: u8,
38 pub visible: bool,
40}
41
42#[derive(Debug, Clone, Deserialize, Serialize)]
44pub struct DynamicNavData {
45 pub has_more: bool,
47 pub items: Vec<DynamicNavItem>,
49 pub offset: String,
51 pub update_baseline: String,
53 #[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}