1use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Deserialize, Serialize)]
9pub struct SpaceCover {
10 pub height: u32,
12 pub url: String,
14 pub width: u32,
16}
17
18#[derive(Debug, Clone, Deserialize, Serialize)]
20pub struct SpaceStat {
21 pub like: String,
23 pub view: Option<String>,
25}
26
27#[derive(Debug, Clone, Deserialize, Serialize)]
29pub struct SpaceItem {
30 pub content: String,
32 pub cover: Option<SpaceCover>,
34 pub jump_url: String,
36 pub opus_id: String,
38 pub stat: SpaceStat,
40}
41
42#[derive(Debug, Clone, Deserialize, Serialize)]
44pub struct SpaceData {
45 pub has_more: bool,
47 pub items: Vec<SpaceItem>,
49 pub offset: String,
51 pub update_num: u32,
53}
54
55#[cfg(test)]
56mod tests {
57 use super::*;
58 use crate::ids::Mid;
59 use crate::opus::{OpusSpaceFeedKind, OpusSpaceFeedParams};
60 use crate::probe::contract::HttpMethod;
61 use crate::probe::endpoint_contract::EndpointContract;
62 use crate::{ApiEnvelope, BpiClient, BpiError, BpiResult};
63 use tracing::info;
64
65 fn contract() -> BpiResult<EndpointContract> {
66 EndpointContract::from_slice(include_bytes!(
67 "../../tests/contracts/opus/space-read/space-feed/contract.json"
68 ))
69 }
70
71 #[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
72 #[tokio::test]
73 async fn test_opus_space_feed() -> Result<(), BpiError> {
74 let bpi = BpiClient::new().expect("client should build");
75 let params = OpusSpaceFeedParams::new(Mid::new(4279370)?)
76 .with_page(1)
77 .with_kind(OpusSpaceFeedKind::All);
78 let resp = bpi.opus().space_feed(params).await;
79 assert!(resp.is_ok());
80 if let Ok(r) = resp {
81 info!("空间图文返回: {:?}", r);
82 }
83 Ok(())
84 }
85
86 #[test]
87 fn opus_space_feed_params_serializes_default_query() -> Result<(), BpiError> {
88 let params = OpusSpaceFeedParams::new(Mid::new(4279370)?);
89
90 assert_eq!(
91 params.query_pairs(),
92 [
93 ("host_mid", "4279370".to_string()),
94 ("page", "0".to_string()),
95 ("type", "all".to_string()),
96 ("web_location", "333.1387".to_string()),
97 ]
98 );
99 Ok(())
100 }
101
102 #[test]
103 fn opus_space_feed_params_serializes_optional_query() -> Result<(), BpiError> {
104 let params = OpusSpaceFeedParams::new(Mid::new(4279370)?)
105 .with_page(2)
106 .with_offset("offset-token")?
107 .with_kind(OpusSpaceFeedKind::Article);
108
109 assert_eq!(
110 params.query_pairs(),
111 [
112 ("host_mid", "4279370".to_string()),
113 ("page", "2".to_string()),
114 ("offset", "offset-token".to_string()),
115 ("type", "article".to_string()),
116 ("web_location", "333.1387".to_string()),
117 ]
118 );
119 Ok(())
120 }
121
122 #[test]
123 fn opus_space_feed_params_rejects_blank_offset() -> Result<(), BpiError> {
124 let err = OpusSpaceFeedParams::new(Mid::new(4279370)?)
125 .with_offset(" ")
126 .unwrap_err();
127
128 assert!(matches!(
129 err,
130 BpiError::InvalidParameter {
131 field: "offset",
132 ..
133 }
134 ));
135 Ok(())
136 }
137
138 #[test]
139 fn opus_space_feed_contract_matches_endpoint_request() -> BpiResult<()> {
140 let contract = contract()?;
141
142 assert_eq!(contract.name, "opus.space_feed");
143 assert_eq!(contract.request.method, HttpMethod::Get);
144 assert_eq!(
145 contract.request.url.as_str(),
146 "https://api.bilibili.com/x/polymer/web-dynamic/v1/opus/feed/space"
147 );
148 assert_eq!(
149 contract.request.query.get("host_mid").map(String::as_str),
150 Some("4279370")
151 );
152 assert_eq!(
153 contract.request.query.get("page").map(String::as_str),
154 Some("0")
155 );
156 assert_eq!(
157 contract.request.query.get("type").map(String::as_str),
158 Some("all")
159 );
160 assert_eq!(
161 contract
162 .request
163 .query
164 .get("web_location")
165 .map(String::as_str),
166 Some("333.1387")
167 );
168 assert_eq!(contract.cases.len(), 3);
169 for case in &contract.cases {
170 assert_eq!(case.response.api_code, Some(0));
171 assert_eq!(case.response.rust_model.as_deref(), Some("SpaceData"));
172 }
173 Ok(())
174 }
175
176 #[test]
177 fn opus_space_feed_response_fixture_parses_declared_model() -> BpiResult<()> {
178 let payload = ApiEnvelope::<SpaceData>::from_slice(include_bytes!(
179 "../../tests/contracts/opus/space-read/space-feed/responses/success.json"
180 ))?
181 .into_payload()?;
182
183 assert!(payload.has_more);
184 assert_eq!(payload.items.len(), 2);
185 assert!(payload.items[0].cover.is_some());
186 assert!(payload.items[1].cover.is_none());
187 assert_eq!(payload.items[1].stat.view.as_deref(), Some("0"));
188 Ok(())
189 }
190
191 fn local_probe_body(profile: &str) -> Option<serde_json::Value> {
192 let path =
193 format!("target/bpi-probe-runs/opus/space-read/space-feed/{profile}.response.json");
194 let bytes = std::fs::read(path).ok()?;
195 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
196 value
197 .get("response")
198 .and_then(|response| response.get("body"))
199 .cloned()
200 }
201
202 #[test]
203 fn opus_space_feed_model_matches_local_probe_outputs_when_available() -> BpiResult<()> {
204 for profile in ["anonymous", "normal", "vip"] {
205 let Some(body) = local_probe_body(profile) else {
206 continue;
207 };
208 let payload = serde_json::from_value::<ApiEnvelope<SpaceData>>(body)?.into_payload()?;
209
210 assert!(!payload.items.is_empty());
211 assert!(!payload.offset.is_empty());
212 }
213 Ok(())
214 }
215}