1use serde::de;
2use serde::{Deserialize, Deserializer, Serialize};
3
4use crate::ids::Mid;
5
6#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
8pub struct LoginNav {
9 #[serde(rename = "isLogin")]
11 pub is_login: bool,
12 #[serde(default, deserialize_with = "deserialize_optional_mid")]
14 pub mid: Option<Mid>,
15 #[serde(default, deserialize_with = "deserialize_optional_string")]
17 pub uname: Option<String>,
18 #[serde(default, deserialize_with = "deserialize_optional_string")]
20 pub face: Option<String>,
21 pub wbi_img: LoginWbiImg,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct LoginWbiImg {
28 pub img_url: String,
30 pub sub_url: String,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36pub struct LoginStats {
37 pub following: u64,
39 pub follower: u64,
41 pub dynamic_count: u64,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
47pub struct LoginCoinBalance {
48 pub money: f64,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(transparent)]
55pub struct LoginTodayCoinExp {
56 pub value: u32,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
62pub struct LoginDailyReward {
63 pub login: bool,
65 pub watch: bool,
67 pub coins: u32,
69 pub share: bool,
71 pub email: bool,
73 pub tel: bool,
75 pub safe_question: bool,
77 pub identify_card: bool,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct LoginAccountInfo {
84 pub mid: Mid,
86 pub uname: String,
88 pub userid: String,
90 pub sign: String,
92 pub birthday: String,
94 pub sex: String,
96 pub nick_free: bool,
98 pub rank: String,
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
104pub struct LoginVipInfo {
105 pub mid: Mid,
107 pub vip_type: u8,
109 pub vip_status: u8,
111 pub vip_due_date: u64,
113 pub vip_pay_type: u8,
115 pub theme_type: u8,
117}
118
119impl LoginVipInfo {
120 pub fn is_active(self) -> bool {
122 self.vip_status == 1 && self.vip_due_date > 0
123 }
124}
125
126fn deserialize_optional_mid<'de, D>(deserializer: D) -> Result<Option<Mid>, D::Error>
127where
128 D: Deserializer<'de>,
129{
130 match Option::<u64>::deserialize(deserializer)? {
131 Some(0) | None => Ok(None),
132 Some(value) => Mid::new(value).map(Some).map_err(de::Error::custom),
133 }
134}
135
136fn deserialize_optional_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
137where
138 D: Deserializer<'de>,
139{
140 Ok(Option::<String>::deserialize(deserializer)?
141 .and_then(|value| (!value.trim().is_empty()).then_some(value)))
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147 use serde::de::DeserializeOwned;
148
149 use crate::probe::endpoint_contract::EndpointContract;
150 use crate::{ApiEnvelope, BpiError};
151
152 const READ_INFO_ENDPOINTS: &[&str] = &["account-info", "coin", "nav", "stat", "today-coin-exp"];
153
154 fn local_vip_info_probe_body(name: &str) -> Option<serde_json::Value> {
155 let path = format!("target/bpi-probe-runs/login/vip-info/vip-info/{name}.response.json");
156 let bytes = std::fs::read(path).ok()?;
157 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
158 value
159 .get("response")
160 .and_then(|response| response.get("body"))
161 .cloned()
162 }
163
164 fn local_read_info_probe_body(endpoint: &str, profile: &str) -> Option<serde_json::Value> {
165 let path =
166 format!("target/bpi-probe-runs/login/read-info/{endpoint}/{profile}.response.json");
167 let bytes = std::fs::read(path).ok()?;
168 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
169 value
170 .get("response")
171 .and_then(|response| response.get("body"))
172 .cloned()
173 }
174
175 fn read_info_payload<T>(endpoint: &str, profile: &str) -> Result<Option<T>, BpiError>
176 where
177 T: DeserializeOwned,
178 {
179 let Some(body) = local_read_info_probe_body(endpoint, profile) else {
180 return Ok(None);
181 };
182
183 serde_json::from_value::<ApiEnvelope<T>>(body)?
184 .into_payload()
185 .map(Some)
186 }
187
188 fn fixture_bytes(
189 endpoint: &str,
190 case: &crate::probe::endpoint_contract::EndpointCase,
191 ) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
192 let fixture = case
193 .response
194 .fixture
195 .as_deref()
196 .ok_or_else(|| BpiError::unsupported_response("contract case missing fixture"))?;
197 let path = format!("tests/contracts/login/{endpoint}/{fixture}");
198
199 Ok(std::fs::read(path)?)
200 }
201
202 fn assert_fixture_matches_model(
203 model: &str,
204 bytes: &[u8],
205 ) -> Result<(), Box<dyn std::error::Error>> {
206 match model {
207 "LoginAccountInfo" => {
208 let payload = ApiEnvelope::<LoginAccountInfo>::from_slice(bytes)?.into_payload()?;
209 assert!(payload.mid.get() > 0);
210 }
211 "LoginCoinBalance" => {
212 let payload = ApiEnvelope::<LoginCoinBalance>::from_slice(bytes)?.into_payload()?;
213 assert!(payload.money >= 0.0);
214 }
215 "LoginNav" => {
216 let payload = ApiEnvelope::<LoginNav>::from_slice(bytes)?.into_payload()?;
217 assert!(payload.is_login);
218 }
219 "LoginStats" => {
220 let payload = ApiEnvelope::<LoginStats>::from_slice(bytes)?.into_payload()?;
221 assert!(payload.following > 0);
222 }
223 "LoginTodayCoinExp" => {
224 let payload =
225 ApiEnvelope::<LoginTodayCoinExp>::from_slice(bytes)?.into_payload()?;
226 assert!(payload.value <= 50);
227 }
228 "LoginDailyReward" => {
229 let payload = ApiEnvelope::<LoginDailyReward>::from_slice(bytes)?.into_payload()?;
230 assert!(payload.coins <= 50);
231 }
232 "LoginVipInfo" => {
233 let payload = ApiEnvelope::<LoginVipInfo>::from_slice(bytes)?.into_payload()?;
234 assert!(payload.mid.get() > 0);
235 }
236 _ => {
237 return Err(Box::new(BpiError::unsupported_response(format!(
238 "unknown login response model {model}"
239 ))));
240 }
241 }
242
243 Ok(())
244 }
245
246 fn assert_login_contract_fixtures_parse(
247 endpoint: &str,
248 ) -> Result<(), Box<dyn std::error::Error>> {
249 let contract = EndpointContract::from_slice(&std::fs::read(format!(
250 "tests/contracts/login/{endpoint}/contract.json"
251 ))?)?;
252
253 for case in &contract.cases {
254 if case.response.fixture.is_none() {
255 assert!(
256 case.response.error.is_some() || case.response.http_status.is_some(),
257 "contract case without fixture must document observed error/status"
258 );
259 continue;
260 }
261
262 let bytes = fixture_bytes(endpoint, case)?;
263 if let Some(model) = &case.response.rust_model {
264 assert_fixture_matches_model(model, &bytes)?;
265 } else if case.response.error.as_deref() == Some("requires_login") {
266 let err = ApiEnvelope::<serde_json::Value>::from_slice(&bytes)?
267 .ensure_success()
268 .unwrap_err();
269 assert!(err.requires_login());
270 }
271 }
272
273 Ok(())
274 }
275
276 #[test]
277 fn login_vip_info_matches_local_probe_outputs_when_available() -> Result<(), BpiError> {
278 let Some(normal_body) = local_vip_info_probe_body("normal") else {
279 return Ok(());
280 };
281 let Some(active_body) = local_vip_info_probe_body("vip") else {
282 return Ok(());
283 };
284
285 let normal: LoginVipInfo =
286 serde_json::from_value::<ApiEnvelope<LoginVipInfo>>(normal_body)?.into_payload()?;
287 let active: LoginVipInfo =
288 serde_json::from_value::<ApiEnvelope<LoginVipInfo>>(active_body)?.into_payload()?;
289
290 assert!(!normal.is_active());
291 assert!(active.is_active());
292 Ok(())
293 }
294
295 #[test]
296 fn login_vip_info_anonymous_probe_returns_login_required_when_available() -> Result<(), BpiError>
297 {
298 let Some(body) = local_vip_info_probe_body("anonymous") else {
299 return Ok(());
300 };
301
302 let err = serde_json::from_value::<ApiEnvelope<LoginVipInfo>>(body)?
303 .ensure_success()
304 .unwrap_err();
305
306 assert!(err.requires_login());
307 Ok(())
308 }
309
310 #[test]
311 fn login_read_info_models_match_local_probe_outputs_when_available() -> Result<(), BpiError> {
312 for profile in ["normal", "vip"] {
313 if let Some(nav) = read_info_payload::<LoginNav>("nav", profile)? {
314 assert!(nav.is_login);
315 assert!(nav.mid.is_some());
316 }
317
318 let _ = read_info_payload::<LoginStats>("stat", profile)?;
319
320 if let Some(coin) = read_info_payload::<LoginCoinBalance>("coin", profile)? {
321 assert!(coin.money >= 0.0);
322 }
323
324 if let Some(exp) = read_info_payload::<LoginTodayCoinExp>("today-coin-exp", profile)? {
325 assert!(exp.value <= 50);
326 }
327
328 if let Some(account) = read_info_payload::<LoginAccountInfo>("account-info", profile)? {
329 assert!(account.mid.get() > 0);
330 assert!(!account.uname.trim().is_empty());
331 }
332 }
333 Ok(())
334 }
335
336 #[test]
337 fn login_read_info_anonymous_probes_return_login_required_when_available()
338 -> Result<(), BpiError> {
339 for endpoint in READ_INFO_ENDPOINTS {
340 let Some(body) = local_read_info_probe_body(endpoint, "anonymous") else {
341 continue;
342 };
343
344 let err = serde_json::from_value::<ApiEnvelope<serde_json::Value>>(body)?
345 .ensure_success()
346 .unwrap_err();
347
348 assert!(err.requires_login());
349 }
350 Ok(())
351 }
352
353 #[test]
354 fn login_contract_response_fixtures_parse_declared_models()
355 -> Result<(), Box<dyn std::error::Error>> {
356 assert_login_contract_fixtures_parse("vip-info")?;
357 assert_login_contract_fixtures_parse("daily-reward")?;
358 for endpoint in READ_INFO_ENDPOINTS {
359 assert_login_contract_fixtures_parse(endpoint)?;
360 }
361 Ok(())
362 }
363}