use serde::de;
use serde::{Deserialize, Deserializer, Serialize};
use crate::ids::Mid;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LoginNav {
#[serde(rename = "isLogin")]
pub is_login: bool,
#[serde(default, deserialize_with = "deserialize_optional_mid")]
pub mid: Option<Mid>,
#[serde(default, deserialize_with = "deserialize_optional_string")]
pub uname: Option<String>,
#[serde(default, deserialize_with = "deserialize_optional_string")]
pub face: Option<String>,
pub wbi_img: LoginWbiImg,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LoginWbiImg {
pub img_url: String,
pub sub_url: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LoginStats {
pub following: u64,
pub follower: u64,
pub dynamic_count: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct LoginCoinBalance {
pub money: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct LoginTodayCoinExp {
pub value: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct LoginDailyReward {
pub login: bool,
pub watch: bool,
pub coins: u32,
pub share: bool,
pub email: bool,
pub tel: bool,
pub safe_question: bool,
pub identify_card: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LoginAccountInfo {
pub mid: Mid,
pub uname: String,
pub userid: String,
pub sign: String,
pub birthday: String,
pub sex: String,
pub nick_free: bool,
pub rank: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct LoginVipInfo {
pub mid: Mid,
pub vip_type: u8,
pub vip_status: u8,
pub vip_due_date: u64,
pub vip_pay_type: u8,
pub theme_type: u8,
}
impl LoginVipInfo {
pub fn is_active(self) -> bool {
self.vip_status == 1 && self.vip_due_date > 0
}
}
fn deserialize_optional_mid<'de, D>(deserializer: D) -> Result<Option<Mid>, D::Error>
where
D: Deserializer<'de>,
{
match Option::<u64>::deserialize(deserializer)? {
Some(0) | None => Ok(None),
Some(value) => Mid::new(value).map(Some).map_err(de::Error::custom),
}
}
fn deserialize_optional_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
Ok(Option::<String>::deserialize(deserializer)?
.and_then(|value| (!value.trim().is_empty()).then_some(value)))
}
#[cfg(test)]
mod tests {
use super::*;
use serde::de::DeserializeOwned;
use crate::probe::endpoint_contract::EndpointContract;
use crate::{ApiEnvelope, BpiError};
const READ_INFO_ENDPOINTS: &[&str] = &["account-info", "coin", "nav", "stat", "today-coin-exp"];
fn local_vip_info_probe_body(name: &str) -> Option<serde_json::Value> {
let path = format!("target/bpi-probe-runs/login/vip-info/vip-info/{name}.response.json");
let bytes = std::fs::read(path).ok()?;
let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
value
.get("response")
.and_then(|response| response.get("body"))
.cloned()
}
fn local_read_info_probe_body(endpoint: &str, profile: &str) -> Option<serde_json::Value> {
let path =
format!("target/bpi-probe-runs/login/read-info/{endpoint}/{profile}.response.json");
let bytes = std::fs::read(path).ok()?;
let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
value
.get("response")
.and_then(|response| response.get("body"))
.cloned()
}
fn read_info_payload<T>(endpoint: &str, profile: &str) -> Result<Option<T>, BpiError>
where
T: DeserializeOwned,
{
let Some(body) = local_read_info_probe_body(endpoint, profile) else {
return Ok(None);
};
serde_json::from_value::<ApiEnvelope<T>>(body)?
.into_payload()
.map(Some)
}
fn fixture_bytes(
endpoint: &str,
case: &crate::probe::endpoint_contract::EndpointCase,
) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let fixture = case
.response
.fixture
.as_deref()
.ok_or_else(|| BpiError::unsupported_response("contract case missing fixture"))?;
let path = format!("tests/contracts/login/{endpoint}/{fixture}");
Ok(std::fs::read(path)?)
}
fn assert_fixture_matches_model(
model: &str,
bytes: &[u8],
) -> Result<(), Box<dyn std::error::Error>> {
match model {
"LoginAccountInfo" => {
let payload = ApiEnvelope::<LoginAccountInfo>::from_slice(bytes)?.into_payload()?;
assert!(payload.mid.get() > 0);
}
"LoginCoinBalance" => {
let payload = ApiEnvelope::<LoginCoinBalance>::from_slice(bytes)?.into_payload()?;
assert!(payload.money >= 0.0);
}
"LoginNav" => {
let payload = ApiEnvelope::<LoginNav>::from_slice(bytes)?.into_payload()?;
assert!(payload.is_login);
}
"LoginStats" => {
let payload = ApiEnvelope::<LoginStats>::from_slice(bytes)?.into_payload()?;
assert!(payload.following > 0);
}
"LoginTodayCoinExp" => {
let payload =
ApiEnvelope::<LoginTodayCoinExp>::from_slice(bytes)?.into_payload()?;
assert!(payload.value <= 50);
}
"LoginDailyReward" => {
let payload = ApiEnvelope::<LoginDailyReward>::from_slice(bytes)?.into_payload()?;
assert!(payload.coins <= 50);
}
"LoginVipInfo" => {
let payload = ApiEnvelope::<LoginVipInfo>::from_slice(bytes)?.into_payload()?;
assert!(payload.mid.get() > 0);
}
_ => {
return Err(Box::new(BpiError::unsupported_response(format!(
"unknown login response model {model}"
))));
}
}
Ok(())
}
fn assert_login_contract_fixtures_parse(
endpoint: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let contract = EndpointContract::from_slice(&std::fs::read(format!(
"tests/contracts/login/{endpoint}/contract.json"
))?)?;
for case in &contract.cases {
if case.response.fixture.is_none() {
assert!(
case.response.error.is_some() || case.response.http_status.is_some(),
"contract case without fixture must document observed error/status"
);
continue;
}
let bytes = fixture_bytes(endpoint, case)?;
if let Some(model) = &case.response.rust_model {
assert_fixture_matches_model(model, &bytes)?;
} else if case.response.error.as_deref() == Some("requires_login") {
let err = ApiEnvelope::<serde_json::Value>::from_slice(&bytes)?
.ensure_success()
.unwrap_err();
assert!(err.requires_login());
}
}
Ok(())
}
#[test]
fn login_vip_info_matches_local_probe_outputs_when_available() -> Result<(), BpiError> {
let Some(normal_body) = local_vip_info_probe_body("normal") else {
return Ok(());
};
let Some(active_body) = local_vip_info_probe_body("vip") else {
return Ok(());
};
let normal: LoginVipInfo =
serde_json::from_value::<ApiEnvelope<LoginVipInfo>>(normal_body)?.into_payload()?;
let active: LoginVipInfo =
serde_json::from_value::<ApiEnvelope<LoginVipInfo>>(active_body)?.into_payload()?;
assert!(!normal.is_active());
assert!(active.is_active());
Ok(())
}
#[test]
fn login_vip_info_anonymous_probe_returns_login_required_when_available() -> Result<(), BpiError>
{
let Some(body) = local_vip_info_probe_body("anonymous") else {
return Ok(());
};
let err = serde_json::from_value::<ApiEnvelope<LoginVipInfo>>(body)?
.ensure_success()
.unwrap_err();
assert!(err.requires_login());
Ok(())
}
#[test]
fn login_read_info_models_match_local_probe_outputs_when_available() -> Result<(), BpiError> {
for profile in ["normal", "vip"] {
if let Some(nav) = read_info_payload::<LoginNav>("nav", profile)? {
assert!(nav.is_login);
assert!(nav.mid.is_some());
}
let _ = read_info_payload::<LoginStats>("stat", profile)?;
if let Some(coin) = read_info_payload::<LoginCoinBalance>("coin", profile)? {
assert!(coin.money >= 0.0);
}
if let Some(exp) = read_info_payload::<LoginTodayCoinExp>("today-coin-exp", profile)? {
assert!(exp.value <= 50);
}
if let Some(account) = read_info_payload::<LoginAccountInfo>("account-info", profile)? {
assert!(account.mid.get() > 0);
assert!(!account.uname.trim().is_empty());
}
}
Ok(())
}
#[test]
fn login_read_info_anonymous_probes_return_login_required_when_available()
-> Result<(), BpiError> {
for endpoint in READ_INFO_ENDPOINTS {
let Some(body) = local_read_info_probe_body(endpoint, "anonymous") else {
continue;
};
let err = serde_json::from_value::<ApiEnvelope<serde_json::Value>>(body)?
.ensure_success()
.unwrap_err();
assert!(err.requires_login());
}
Ok(())
}
#[test]
fn login_contract_response_fixtures_parse_declared_models()
-> Result<(), Box<dyn std::error::Error>> {
assert_login_contract_fixtures_parse("vip-info")?;
assert_login_contract_fixtures_parse("daily-reward")?;
for endpoint in READ_INFO_ENDPOINTS {
assert_login_contract_fixtures_parse(endpoint)?;
}
Ok(())
}
}