1use crate::ids::Bvid;
5use crate::{BpiError, BpiResult};
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct ActivityInfoData {
11 pub id: u64,
13 pub stime: i64,
15 pub etime: i64,
17 pub ctime: i64,
19 pub mtime: i64,
21 pub name: String,
23 pub act_url: String,
25 pub cover: String,
27 pub dic: String,
29 pub h5_cover: String,
31 pub android_url: String,
33 pub ios_url: String,
35 pub child_sids: String,
37 pub lid: Option<i64>,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct ActivityInfoParams {
44 sid: u64,
45 bvid: Option<Bvid>,
46}
47
48impl ActivityInfoParams {
49 pub fn new(sid: u64) -> BpiResult<Self> {
51 if sid == 0 {
52 return Err(BpiError::invalid_parameter("sid", "sid must be non-zero"));
53 }
54
55 Ok(Self { sid, bvid: None })
56 }
57
58 pub fn with_bvid(mut self, bvid: Bvid) -> Self {
60 self.bvid = Some(bvid);
61 self
62 }
63
64 pub(crate) fn query_pairs(&self) -> Vec<(&'static str, String)> {
65 let mut params = vec![("sid", self.sid.to_string())];
66
67 if let Some(bvid) = self.bvid.as_ref() {
68 params.push(("bvid", bvid.to_string()));
69 }
70
71 params
72 }
73}
74
75#[cfg(test)]
76mod tests {
77 use super::*;
78 use crate::probe::contract::HttpMethod;
79 use crate::probe::endpoint_contract::EndpointContract;
80 use crate::{ApiEnvelope, BpiClient, BpiResult};
81
82 const TEST_ACTIVITY_ID: u64 = 4_017_552;
83 const TEST_ACTIVITY_BVID: &str = "BV1mKY4e8ELy";
84
85 fn contract() -> BpiResult<EndpointContract> {
86 EndpointContract::from_slice(include_bytes!(
87 "../../tests/contracts/activity/info/contract.json"
88 ))
89 }
90
91 #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
92 #[tokio::test]
93 async fn test_activity_info() -> Result<(), Box<BpiError>> {
94 let bpi = BpiClient::new().expect("client should build");
95 let params = ActivityInfoParams::new(4017552)?
96 .with_bvid("BV1mKY4e8ELy".parse().expect("bvid should be valid"));
97
98 let data = bpi.activity().info(params).await?;
99 tracing::info!("{:#?}", data);
100
101 Ok(())
102 }
103
104 #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
105 #[tokio::test]
106 async fn test_activity_info_without_bvid() -> Result<(), Box<BpiError>> {
107 let bpi = BpiClient::new().expect("client should build");
108 let sid = 4017552;
109 let params = ActivityInfoParams::new(sid)?;
110
111 let data = bpi.activity().info(params).await?;
112 tracing::info!("{:#?}", data);
113
114 assert_eq!(data.id, sid);
115
116 Ok(())
117 }
118
119 #[test]
120 fn activity_info_params_serializes_required_query() -> Result<(), BpiError> {
121 let params = ActivityInfoParams::new(4017552)?;
122
123 assert_eq!(params.query_pairs(), vec![("sid", "4017552".to_string())]);
124 Ok(())
125 }
126
127 #[test]
128 fn activity_info_params_serializes_bvid_query() -> Result<(), BpiError> {
129 let params = ActivityInfoParams::new(4017552)?.with_bvid("BV1mKY4e8ELy".parse()?);
130
131 assert_eq!(
132 params.query_pairs(),
133 vec![
134 ("sid", "4017552".to_string()),
135 ("bvid", "BV1mKY4e8ELy".to_string()),
136 ]
137 );
138 Ok(())
139 }
140
141 #[test]
142 fn activity_info_params_rejects_zero_sid() {
143 let err = ActivityInfoParams::new(0).unwrap_err();
144
145 assert!(matches!(
146 err,
147 BpiError::InvalidParameter { field: "sid", .. }
148 ));
149 }
150
151 #[test]
152 fn activity_info_contract_matches_endpoint_request() -> BpiResult<()> {
153 let contract = contract()?;
154
155 assert_eq!(contract.name, "activity.info");
156 assert_eq!(contract.request.method, HttpMethod::Get);
157 assert_eq!(
158 contract.request.url.as_str(),
159 "https://api.bilibili.com/x/activity/subject/info"
160 );
161 assert_eq!(
162 contract.request.query.get("sid").map(String::as_str),
163 Some("4017552")
164 );
165 assert_eq!(
166 contract.request.query.get("bvid").map(String::as_str),
167 Some(TEST_ACTIVITY_BVID)
168 );
169 assert_eq!(contract.cases.len(), 3);
170 assert_eq!(
171 contract.cases[0].response.rust_model.as_deref(),
172 Some("ActivityInfoData")
173 );
174 Ok(())
175 }
176
177 #[test]
178 fn activity_info_response_fixtures_parse_declared_model() -> BpiResult<()> {
179 for bytes in [
180 include_bytes!("../../tests/contracts/activity/info/responses/anonymous.success.json")
181 .as_slice(),
182 include_bytes!("../../tests/contracts/activity/info/responses/normal.success.json")
183 .as_slice(),
184 include_bytes!("../../tests/contracts/activity/info/responses/vip.success.json")
185 .as_slice(),
186 ] {
187 let payload = ApiEnvelope::<ActivityInfoData>::from_slice(bytes)?.into_payload()?;
188
189 assert_eq!(payload.id, TEST_ACTIVITY_ID);
190 assert!(payload.lid.is_some());
191 }
192 Ok(())
193 }
194
195 fn local_probe_body(profile: &str) -> Option<serde_json::Value> {
196 let path = format!("target/bpi-probe-runs/activity/public/info/{profile}.response.json");
197 let bytes = std::fs::read(path).ok()?;
198 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
199 value
200 .get("response")
201 .and_then(|response| response.get("body"))
202 .cloned()
203 }
204
205 #[test]
206 fn activity_info_model_matches_local_probe_outputs_when_available() -> BpiResult<()> {
207 for profile in ["anonymous", "normal", "vip"] {
208 let Some(body) = local_probe_body(profile) else {
209 continue;
210 };
211 let payload =
212 serde_json::from_value::<ApiEnvelope<ActivityInfoData>>(body)?.into_payload()?;
213
214 assert_eq!(payload.id, TEST_ACTIVITY_ID);
215 assert!(payload.lid.is_some());
216 }
217 Ok(())
218 }
219}