1use serde::{Deserialize, Deserializer, Serialize, de};
2
3use crate::models::{LevelInfo, Official, Pendant, Vip};
4
5#[derive(Debug, Serialize, Clone, Deserialize)]
6pub struct DynamicCardData {
7 pub card: DynamicCard,
8}
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct DynamicCard {
12 pub desc: Desc,
13 pub card: String,
14 pub extend_json: String,
15 pub display: serde_json::Value,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct Desc {
20 pub uid: i64,
21 #[serde(rename = "type")]
22 pub type_field: i64,
23 pub rid: i64,
24 pub acl: i64,
25 pub view: i64,
26 pub repost: i64,
27 pub comment: i64,
28 pub like: i64,
29 pub is_liked: i64,
30 pub dynamic_id: i64,
31 pub timestamp: i64,
32 pub pre_dy_id: i64,
33 pub orig_dy_id: i64,
34 pub orig_type: i64,
35 pub user_profile: UserProfile,
36 pub spec_type: i64,
37 pub uid_type: i64,
38 pub stype: i64,
39 pub r_type: i64,
40 pub inner_id: i64,
41 pub status: i64,
42 pub dynamic_id_str: String,
43 pub pre_dy_id_str: String,
44 pub orig_dy_id_str: String,
45 pub rid_str: String,
46 pub bvid: String,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct UserProfile {
51 pub info: Info,
52 pub card: Card,
53 pub vip: Vip,
54 pub pendant: Pendant,
55 pub rank: String,
56 pub sign: String,
57 pub level_info: LevelInfo,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct Info {
62 pub uid: i64,
63 pub uname: String,
64 pub face: String,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct Card {
69 pub official_verify: OfficialVerify,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct OfficialVerify {
74 #[serde(rename = "type")]
75 pub type_field: i64,
76}
77
78#[derive(Debug, Serialize, Clone, Deserialize)]
79pub struct RecentUpData {
80 pub live_users: Option<serde_json::Value>,
82 pub my_info: Option<MyInfo>,
84 pub up_list: Vec<UpUser>,
86}
87
88#[derive(Debug, Serialize, Clone, Deserialize)]
90pub struct MyInfo {
91 #[serde(deserialize_with = "deserialize_i32_from_string_or_number")]
93 pub dyns: i32,
94 pub face: String,
96 pub follower: String,
98 #[serde(deserialize_with = "deserialize_i32_from_string_or_number")]
100 pub following: i32,
101 pub level_info: LevelInfo,
103 #[serde(deserialize_with = "deserialize_i64_from_string_or_number")]
105 pub mid: i64,
106 pub name: String,
108 #[serde(rename = "official")]
110 pub official: Official,
111 pub space_bg: String,
113 pub vip: Vip,
115}
116
117#[derive(Debug, Serialize, Clone, Deserialize)]
119pub struct UpUser {
120 pub face: String,
122 pub has_update: bool,
124 pub is_reserve_recall: bool,
126 #[serde(deserialize_with = "deserialize_i64_from_string_or_number")]
128 pub mid: i64,
129 pub uname: String,
131}
132
133fn deserialize_i32_from_string_or_number<'de, D>(deserializer: D) -> Result<i32, D::Error>
134where
135 D: Deserializer<'de>,
136{
137 let value = serde_json::Value::deserialize(deserializer)?;
138 let value = parse_i64_from_string_or_number(value)?;
139 i32::try_from(value).map_err(|_| de::Error::custom("value must fit in i32"))
140}
141
142fn deserialize_i64_from_string_or_number<'de, D>(deserializer: D) -> Result<i64, D::Error>
143where
144 D: Deserializer<'de>,
145{
146 parse_i64_from_string_or_number(serde_json::Value::deserialize(deserializer)?)
147}
148
149fn parse_i64_from_string_or_number<E>(value: serde_json::Value) -> Result<i64, E>
150where
151 E: de::Error,
152{
153 match value {
154 serde_json::Value::Number(number) => number
155 .as_i64()
156 .ok_or_else(|| E::custom("value must be an integer")),
157 serde_json::Value::String(text) => text
158 .parse::<i64>()
159 .map_err(|_| E::custom("value must be a numeric string")),
160 _ => Err(E::custom("value must be a string or number")),
161 }
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167 use crate::probe::contract::HttpMethod;
168 use crate::probe::endpoint_contract::EndpointContract;
169 use crate::{ApiEnvelope, BpiClient, BpiResult};
170
171 fn recent_up_contract() -> BpiResult<EndpointContract> {
172 EndpointContract::from_slice(include_bytes!(
173 "../../tests/contracts/dynamic/content/recent-up/contract.json"
174 ))
175 }
176
177 #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
178 #[tokio::test]
179 async fn test_dynamic_recent_up_list() {
180 let bpi = BpiClient::new().expect("client should build");
181 let resp = bpi.dynamic().recent_up().await;
182 assert!(resp.is_ok());
183 if let Ok(data) = resp {
184 tracing::info!("{:#?}", data.up_list.len());
185 }
186 }
187
188 #[test]
189 fn dynamic_recent_up_contract_matches_endpoint_request() -> BpiResult<()> {
190 let contract = recent_up_contract()?;
191
192 assert_eq!(contract.name, "dynamic.recent_up");
193 assert_eq!(contract.request.method, HttpMethod::Get);
194 assert_eq!(
195 contract.request.url.as_str(),
196 "https://api.bilibili.com/x/polymer/web-dynamic/v1/portal"
197 );
198 assert!(contract.request.query.is_empty());
199 assert_eq!(contract.cases.len(), 3);
200 assert_eq!(
201 contract.cases[0].response.error.as_deref(),
202 Some("requires_login")
203 );
204 assert_eq!(
205 contract.cases[1].response.rust_model.as_deref(),
206 Some("RecentUpData")
207 );
208 Ok(())
209 }
210
211 #[test]
212 fn dynamic_recent_up_response_fixtures_parse_declared_model() -> BpiResult<()> {
213 for bytes in [
214 include_bytes!(
215 "../../tests/contracts/dynamic/content/recent-up/responses/normal.success.json"
216 )
217 .as_slice(),
218 include_bytes!(
219 "../../tests/contracts/dynamic/content/recent-up/responses/vip.success.json"
220 )
221 .as_slice(),
222 ] {
223 let payload = ApiEnvelope::<RecentUpData>::from_slice(bytes)?.into_payload()?;
224 let my_info = payload
225 .my_info
226 .expect("sanitized fixture should include my_info");
227 assert_eq!(my_info.dyns, 0);
228 assert_eq!(my_info.following, 0);
229 assert_eq!(my_info.mid, 1);
230 }
231 Ok(())
232 }
233
234 #[test]
235 fn dynamic_recent_up_anonymous_fixture_records_login_error() -> BpiResult<()> {
236 let err = ApiEnvelope::<serde_json::Value>::from_slice(include_bytes!(
237 "../../tests/contracts/dynamic/content/recent-up/responses/anonymous.requires_login.json"
238 ))?
239 .ensure_success()
240 .unwrap_err();
241
242 assert_eq!(err.code(), Some(-101));
243 Ok(())
244 }
245
246 fn recent_up_local_probe_body(profile: &str) -> Option<serde_json::Value> {
247 let path = format!(
248 "target/bpi-probe-runs/dynamic/content-readonly/recent-up/{profile}.response.json"
249 );
250 local_probe_response_body(&path)
251 }
252
253 fn local_probe_response_body(path: &str) -> Option<serde_json::Value> {
254 let bytes = std::fs::read(path).ok()?;
255 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
256 value
257 .get("response")
258 .and_then(|response| response.get("body"))
259 .cloned()
260 }
261
262 #[test]
263 fn dynamic_recent_up_model_matches_local_probe_outputs_when_available() -> BpiResult<()> {
264 for profile in ["normal", "vip"] {
265 let Some(body) = recent_up_local_probe_body(profile) else {
266 continue;
267 };
268 let payload =
269 serde_json::from_value::<ApiEnvelope<RecentUpData>>(body)?.into_payload()?;
270 assert!(payload.my_info.is_some());
271 }
272
273 if let Some(body) = recent_up_local_probe_body("anonymous") {
274 let err = serde_json::from_value::<ApiEnvelope<serde_json::Value>>(body)?
275 .ensure_success()
276 .unwrap_err();
277 assert_eq!(err.code(), Some(-101));
278 }
279 Ok(())
280 }
281}