1use serde::{Deserialize, Serialize};
6
7#[cfg(test)]
8const B23_SHORT_LINK_ENDPOINT: &str = "https://api.biliapi.net/x/share/click";
9
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12pub struct ShortLinkData {
13 pub content: String,
15
16 pub count: i32,
18
19 #[serde(skip_serializing, skip_deserializing)]
21 pub link: String,
22 #[serde(skip_serializing, skip_deserializing)]
24 pub title: String,
25}
26
27impl ShortLinkData {
28 pub fn extract(&mut self) {
29 if let Some(pos) = self.content.find("https://b23.tv/") {
30 self.link = self.content[pos..].to_string().trim().to_string();
31 self.title = self.content[..pos].trim().to_string();
32 } else {
33 self.link = String::new();
34 self.title = self.content.clone();
35 }
36 }
37}
38
39#[cfg(test)]
40mod tests {
41 use super::*;
42 use crate::ApiEnvelope;
43 use crate::BpiError;
44 use crate::ids::Aid;
45 use crate::misc::MiscB23ShortLinkParams;
46 use crate::probe::contract::HttpMethod;
47 use crate::probe::endpoint_contract::EndpointContract;
48
49 fn local_b23_short_link_probe_body(profile: &str) -> Option<serde_json::Value> {
50 let path = format!("target/bpi-probe-runs/misc/b23tv/short-link/{profile}.response.json");
51 let bytes = std::fs::read(path).ok()?;
52 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
53 value
54 .get("response")
55 .and_then(|response| response.get("body"))
56 .cloned()
57 }
58
59 #[test]
60 fn misc_b23_short_link_contract_matches_endpoint_request() -> Result<(), BpiError> {
61 let contract = EndpointContract::from_slice(include_bytes!(
62 "../../tests/contracts/misc/b23tv/short-link/contract.json"
63 ))?;
64
65 assert_eq!(contract.name, "misc.b23tv.short_link");
66 assert_eq!(contract.request.method, HttpMethod::Post);
67 assert_eq!(contract.request.url.as_str(), B23_SHORT_LINK_ENDPOINT);
68 assert!(contract.request.query.is_empty());
69
70 let form = contract
71 .request
72 .form
73 .as_ref()
74 .ok_or_else(|| BpiError::unsupported_response("missing b23tv contract form"))?;
75 assert_eq!(form.get("platform").map(String::as_str), Some("unix"));
76 assert_eq!(form.get("share_channel").map(String::as_str), Some("COPY"));
77 assert_eq!(
78 form.get("share_id").map(String::as_str),
79 Some("main.ugc-video-detail.0.0.pv")
80 );
81 assert_eq!(form.get("share_mode").map(String::as_str), Some("4"));
82 assert_eq!(form.get("oid").map(String::as_str), Some("10001"));
83 assert_eq!(form.get("buvid").map(String::as_str), Some("qwq"));
84 assert_eq!(form.get("build").map(String::as_str), Some("6114514"));
85 assert_eq!(contract.cases.len(), 3);
86 Ok(())
87 }
88
89 #[test]
90 fn misc_b23_short_link_contract_covers_profiles() -> Result<(), BpiError> {
91 let contract = EndpointContract::from_slice(include_bytes!(
92 "../../tests/contracts/misc/b23tv/short-link/contract.json"
93 ))?;
94
95 let anonymous = &contract.cases[0];
96 assert_eq!(anonymous.profile.as_deref(), Some("anonymous"));
97 assert!(!anonymous.auth.requires_cookie());
98
99 for case in &contract.cases[1..] {
100 assert!(matches!(case.name.as_str(), "normal" | "vip"));
101 assert!(case.auth.requires_cookie());
102 assert_eq!(case.response.api_code, Some(0));
103 assert_eq!(case.response.rust_model.as_deref(), Some("ShortLinkData"));
104 }
105 Ok(())
106 }
107
108 #[test]
109 fn misc_b23_short_link_response_fixture_parses_declared_model() -> Result<(), BpiError> {
110 let mut data = ApiEnvelope::<ShortLinkData>::from_slice(include_bytes!(
111 "../../tests/contracts/misc/b23tv/short-link/responses/success.json"
112 ))?
113 .into_payload()?;
114
115 data.extract();
116
117 assert_eq!(data.count, 0);
118 assert_eq!(data.title, "sanitized-title");
119 assert_eq!(data.link, "https://b23.tv/sanitized");
120 Ok(())
121 }
122
123 #[test]
124 fn misc_b23_short_link_model_matches_local_probe_outputs_when_available() -> Result<(), BpiError>
125 {
126 for profile in ["anonymous", "normal", "vip"] {
127 let Some(body) = local_b23_short_link_probe_body(profile) else {
128 continue;
129 };
130
131 let mut data =
132 serde_json::from_value::<ApiEnvelope<ShortLinkData>>(body)?.into_payload()?;
133 data.extract();
134
135 assert_eq!(data.count, 0);
136 assert!(data.link.starts_with("https://b23.tv/"));
137 assert!(!data.title.trim().is_empty());
138 }
139 Ok(())
140 }
141
142 #[test]
143 fn misc_b23_short_link_params_serializes_default_form() -> Result<(), BpiError> {
144 let params = MiscB23ShortLinkParams::new(Aid::new(10001)?);
145
146 assert_eq!(
147 params.form_pairs(),
148 [
149 ("platform", "unix".to_string()),
150 ("share_channel", "COPY".to_string()),
151 ("share_id", "main.ugc-video-detail.0.0.pv".to_string()),
152 ("share_mode", "4".to_string()),
153 ("oid", "10001".to_string()),
154 ("buvid", "qwq".to_string()),
155 ("build", "6114514".to_string()),
156 ]
157 );
158 Ok(())
159 }
160
161 #[test]
162 fn misc_b23_short_link_params_serializes_custom_form() -> Result<(), BpiError> {
163 let params = MiscB23ShortLinkParams::new(Aid::new(10001)?)
164 .with_platform("web")?
165 .with_share_channel("WEIXIN")?
166 .with_share_id("custom.share.id")?
167 .with_share_mode(5)
168 .with_buvid("custom-buvid")?
169 .with_build(123456);
170
171 assert_eq!(
172 params.form_pairs(),
173 [
174 ("platform", "web".to_string()),
175 ("share_channel", "WEIXIN".to_string()),
176 ("share_id", "custom.share.id".to_string()),
177 ("share_mode", "5".to_string()),
178 ("oid", "10001".to_string()),
179 ("buvid", "custom-buvid".to_string()),
180 ("build", "123456".to_string()),
181 ]
182 );
183 Ok(())
184 }
185
186 #[test]
187 fn misc_b23_short_link_params_rejects_blank_buvid() -> Result<(), BpiError> {
188 let err = MiscB23ShortLinkParams::new(Aid::new(10001)?)
189 .with_buvid(" ")
190 .unwrap_err();
191
192 assert!(matches!(
193 err,
194 BpiError::InvalidParameter { field: "buvid", .. }
195 ));
196 Ok(())
197 }
198}