1use crate::{BilibiliRequest, BpiClient, BpiResult};
2
3use super::model::{
4 UserAlbumCount, UserBangumiFollowList, UserBatchCard, UserBatchInfo, UserCardProfile,
5 UserFollowTag, UserFollowers, UserFollowings, UserMedalWall, UserNameToUid, UserNavStat,
6 UserRelationStat, UserSpaceNotice, UserSpaceProfile, UserUpStat, UserUploadedVideos,
7};
8use super::params::{
9 UserAlbumCountParams, UserBangumiFollowListParams, UserCardParams, UserCardsParams,
10 UserFollowersParams, UserFollowingsParams, UserInfosParams, UserMedalWallParams,
11 UserNameToUidParams, UserNavStatParams, UserRelationStatParams, UserSpaceNoticeParams,
12 UserSpaceParams, UserUpStatParams, UserUploadedVideosParams,
13};
14
15const ALBUM_COUNT_ENDPOINT: &str = "https://api.vc.bilibili.com/link_draw/v1/doc/upload_count";
16const BANGUMI_FOLLOW_LIST_ENDPOINT: &str = "https://api.bilibili.com/x/space/bangumi/follow/list";
17const CARD_ENDPOINT: &str = "https://api.bilibili.com/x/web-interface/card";
18const CARDS_ENDPOINT: &str = "https://api.vc.bilibili.com/account/v1/user/cards";
19const FOLLOWERS_ENDPOINT: &str = "https://api.bilibili.com/x/relation/fans";
20const FOLLOWINGS_ENDPOINT: &str = "https://api.bilibili.com/x/relation/followings";
21const FOLLOW_TAGS_ENDPOINT: &str = "https://api.bilibili.com/x/relation/tags";
22const INFOS_ENDPOINT: &str = "https://api.vc.bilibili.com/x/im/user_infos";
23const MEDAL_WALL_ENDPOINT: &str = "https://api.live.bilibili.com/xlive/web-ucenter/user/MedalWall";
24const NAME_TO_UID_ENDPOINT: &str = "https://api.bilibili.com/x/polymer/web-dynamic/v1/name-to-uid";
25const NAV_STAT_ENDPOINT: &str = "https://api.bilibili.com/x/space/navnum";
26const RELATION_STAT_ENDPOINT: &str = "https://api.bilibili.com/x/relation/stat";
27const SPACE_INFO_ENDPOINT: &str = "https://api.bilibili.com/x/space/wbi/acc/info";
28const SPACE_NOTICE_ENDPOINT: &str = "https://api.bilibili.com/x/space/notice";
29const UP_STAT_ENDPOINT: &str = "https://api.bilibili.com/x/space/upstat";
30const UPLOADED_VIDEOS_ENDPOINT: &str = "https://api.bilibili.com/x/space/wbi/arc/search";
31
32#[derive(Clone, Copy)]
34pub struct UserClient<'a> {
35 pub(crate) client: &'a BpiClient,
36}
37
38impl<'a> UserClient<'a> {
39 pub(crate) fn new(client: &'a BpiClient) -> Self {
40 Self { client }
41 }
42
43 #[cfg(test)]
44 pub(crate) fn card_endpoint(&self) -> &'static str {
45 CARD_ENDPOINT
46 }
47
48 #[cfg(test)]
49 pub(crate) fn cards_endpoint(&self) -> &'static str {
50 CARDS_ENDPOINT
51 }
52
53 #[cfg(test)]
54 pub(crate) fn infos_endpoint(&self) -> &'static str {
55 INFOS_ENDPOINT
56 }
57
58 #[cfg(test)]
59 pub(crate) fn bangumi_follow_list_endpoint(&self) -> &'static str {
60 BANGUMI_FOLLOW_LIST_ENDPOINT
61 }
62
63 #[cfg(test)]
64 pub(crate) fn followings_endpoint(&self) -> &'static str {
65 FOLLOWINGS_ENDPOINT
66 }
67
68 #[cfg(test)]
69 pub(crate) fn followers_endpoint(&self) -> &'static str {
70 FOLLOWERS_ENDPOINT
71 }
72
73 #[cfg(test)]
74 pub(crate) fn follow_tags_endpoint(&self) -> &'static str {
75 FOLLOW_TAGS_ENDPOINT
76 }
77
78 #[cfg(test)]
79 pub(crate) fn medal_wall_endpoint(&self) -> &'static str {
80 MEDAL_WALL_ENDPOINT
81 }
82
83 #[cfg(test)]
84 pub(crate) fn album_count_endpoint(&self) -> &'static str {
85 ALBUM_COUNT_ENDPOINT
86 }
87
88 #[cfg(test)]
89 pub(crate) fn name_to_uid_endpoint(&self) -> &'static str {
90 NAME_TO_UID_ENDPOINT
91 }
92
93 #[cfg(test)]
94 pub(crate) fn relation_stat_endpoint(&self) -> &'static str {
95 RELATION_STAT_ENDPOINT
96 }
97
98 #[cfg(test)]
99 pub(crate) fn nav_stat_endpoint(&self) -> &'static str {
100 NAV_STAT_ENDPOINT
101 }
102
103 #[cfg(test)]
104 pub(crate) fn space_info_endpoint(&self) -> &'static str {
105 SPACE_INFO_ENDPOINT
106 }
107
108 #[cfg(test)]
109 pub(crate) fn space_notice_endpoint(&self) -> &'static str {
110 SPACE_NOTICE_ENDPOINT
111 }
112
113 #[cfg(test)]
114 pub(crate) fn uploaded_videos_endpoint(&self) -> &'static str {
115 UPLOADED_VIDEOS_ENDPOINT
116 }
117
118 #[cfg(test)]
119 pub(crate) fn up_stat_endpoint(&self) -> &'static str {
120 UP_STAT_ENDPOINT
121 }
122
123 pub async fn card(&self, params: UserCardParams) -> BpiResult<UserCardProfile> {
125 self.client
126 .get(CARD_ENDPOINT)
127 .query(¶ms.query_pairs())
128 .send_bpi_payload("user.card")
129 .await
130 }
131
132 pub async fn cards(&self, params: UserCardsParams) -> BpiResult<Vec<UserBatchCard>> {
134 self.client
135 .get(CARDS_ENDPOINT)
136 .query(¶ms.query_pairs())
137 .send_bpi_payload("user.cards")
138 .await
139 }
140
141 pub async fn infos(&self, params: UserInfosParams) -> BpiResult<Vec<UserBatchInfo>> {
143 self.client
144 .get(INFOS_ENDPOINT)
145 .query(¶ms.query_pairs())
146 .send_bpi_payload("user.infos")
147 .await
148 }
149
150 pub async fn album_count(&self, params: UserAlbumCountParams) -> BpiResult<UserAlbumCount> {
152 self.client
153 .get(ALBUM_COUNT_ENDPOINT)
154 .query(¶ms.query_pairs())
155 .send_bpi_payload("user.album_count")
156 .await
157 }
158
159 pub async fn bangumi_follow_list(
161 &self,
162 params: UserBangumiFollowListParams,
163 ) -> BpiResult<UserBangumiFollowList> {
164 self.client
165 .get(BANGUMI_FOLLOW_LIST_ENDPOINT)
166 .with_bilibili_headers()
167 .query(¶ms.query_pairs())
168 .send_bpi_payload("user.bangumi_follow_list")
169 .await
170 }
171
172 pub async fn followings(&self, params: UserFollowingsParams) -> BpiResult<UserFollowings> {
174 self.client
175 .get(FOLLOWINGS_ENDPOINT)
176 .with_bilibili_headers()
177 .query(¶ms.query_pairs())
178 .send_bpi_payload("user.followings")
179 .await
180 }
181
182 pub async fn followers(&self, params: UserFollowersParams) -> BpiResult<UserFollowers> {
184 self.client
185 .get(FOLLOWERS_ENDPOINT)
186 .with_bilibili_headers()
187 .query(¶ms.query_pairs())
188 .send_bpi_payload("user.followers")
189 .await
190 }
191
192 pub async fn follow_tags(&self) -> BpiResult<Vec<UserFollowTag>> {
194 self.client
195 .get(FOLLOW_TAGS_ENDPOINT)
196 .with_bilibili_headers()
197 .send_bpi_payload("user.follow_tags")
198 .await
199 }
200
201 pub async fn medal_wall(&self, params: UserMedalWallParams) -> BpiResult<UserMedalWall> {
203 self.client
204 .get(MEDAL_WALL_ENDPOINT)
205 .with_bilibili_headers()
206 .query(¶ms.query_pairs())
207 .send_bpi_payload("user.medal_wall")
208 .await
209 }
210
211 pub async fn name_to_uid(&self, params: UserNameToUidParams) -> BpiResult<UserNameToUid> {
213 self.client
214 .get(NAME_TO_UID_ENDPOINT)
215 .query(¶ms.query_pairs())
216 .send_bpi_payload("user.name_to_uid")
217 .await
218 }
219
220 pub async fn relation_stat(
222 &self,
223 params: UserRelationStatParams,
224 ) -> BpiResult<UserRelationStat> {
225 self.client
226 .get(RELATION_STAT_ENDPOINT)
227 .query(¶ms.query_pairs())
228 .send_bpi_payload("user.relation_stat")
229 .await
230 }
231
232 pub async fn nav_stat(&self, params: UserNavStatParams) -> BpiResult<UserNavStat> {
234 self.client
235 .get(NAV_STAT_ENDPOINT)
236 .query(¶ms.query_pairs())
237 .send_bpi_payload("user.nav_stat")
238 .await
239 }
240
241 pub async fn up_stat(&self, params: UserUpStatParams) -> BpiResult<UserUpStat> {
243 self.client
244 .get(UP_STAT_ENDPOINT)
245 .query(¶ms.query_pairs())
246 .send_bpi_payload("user.up_stat")
247 .await
248 }
249
250 pub async fn space_info(&self, params: UserSpaceParams) -> BpiResult<UserSpaceProfile> {
252 let signed_params = self.client.sign_wbi_params(params.query_pairs()).await?;
253
254 self.client
255 .get(SPACE_INFO_ENDPOINT)
256 .query(&signed_params)
257 .send_bpi_payload("user.space_info")
258 .await
259 }
260
261 pub async fn space_notice(&self, params: UserSpaceNoticeParams) -> BpiResult<UserSpaceNotice> {
263 self.client
264 .get(SPACE_NOTICE_ENDPOINT)
265 .query(¶ms.query_pairs())
266 .send_bpi_payload("user.space_notice")
267 .await
268 }
269
270 pub async fn uploaded_videos(
272 &self,
273 params: UserUploadedVideosParams,
274 ) -> BpiResult<UserUploadedVideos> {
275 let signed_params = self.client.sign_wbi_params(params.query_pairs()).await?;
276
277 self.client
278 .get(UPLOADED_VIDEOS_ENDPOINT)
279 .query(&signed_params)
280 .send_bpi_payload("user.uploaded_videos")
281 .await
282 }
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288 use crate::{
289 ApiEnvelope, BpiClient, BpiError, BpiResult,
290 probe::{contract::HttpMethod, endpoint_contract::EndpointContract},
291 };
292 use serde::de::DeserializeOwned;
293
294 fn contract(endpoint: &str) -> BpiResult<EndpointContract> {
295 let bytes: &[u8] = match endpoint {
296 "album-count" => {
297 include_bytes!("../../tests/contracts/user/public-read/album-count/contract.json")
298 }
299 "bangumi-follow-list" => include_bytes!(
300 "../../tests/contracts/user/public-read/bangumi-follow-list/contract.json"
301 ),
302 "card" => include_bytes!("../../tests/contracts/user/public-read/card/contract.json"),
303 "cards" => {
304 include_bytes!("../../tests/contracts/user/public-read/cards/contract.json")
305 }
306 "follow-tags" => {
307 include_bytes!("../../tests/contracts/user/relation-read/follow-tags/contract.json")
308 }
309 "followers" => {
310 include_bytes!("../../tests/contracts/user/relation-read/followers/contract.json")
311 }
312 "followings" => {
313 include_bytes!("../../tests/contracts/user/relation-read/followings/contract.json")
314 }
315 "infos" => {
316 include_bytes!("../../tests/contracts/user/public-read/infos/contract.json")
317 }
318 "medal-wall" => {
319 include_bytes!("../../tests/contracts/user/public-read/medal-wall/contract.json")
320 }
321 "name-to-uid" => {
322 include_bytes!("../../tests/contracts/user/public-read/name-to-uid/contract.json")
323 }
324 "nav-stat" => {
325 include_bytes!("../../tests/contracts/user/public-read/nav-stat/contract.json")
326 }
327 "relation-stat" => {
328 include_bytes!("../../tests/contracts/user/public-read/relation-stat/contract.json")
329 }
330 "space-info" => {
331 include_bytes!("../../tests/contracts/user/public-read/space-info/contract.json")
332 }
333 "space-notice" => {
334 include_bytes!("../../tests/contracts/user/public-read/space-notice/contract.json")
335 }
336 "up-stat" => {
337 include_bytes!("../../tests/contracts/user/public-read/up-stat/contract.json")
338 }
339 "uploaded-videos" => include_bytes!(
340 "../../tests/contracts/user/public-read/uploaded-videos/contract.json"
341 ),
342 _ => {
343 return Err(BpiError::invalid_parameter(
344 "endpoint",
345 "unknown user contract",
346 ));
347 }
348 };
349
350 EndpointContract::from_slice(bytes)
351 }
352
353 fn local_probe_body(batch: &str, endpoint: &str, profile: &str) -> Option<serde_json::Value> {
354 let path = format!("target/bpi-probe-runs/user/{batch}/{endpoint}/{profile}.response.json");
355 let bytes = std::fs::read(path).ok()?;
356 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
357 value
358 .get("response")
359 .and_then(|response| response.get("body"))
360 .cloned()
361 }
362
363 fn parse_local_probe_outputs<T>(batch: &str, endpoint: &str, profiles: &[&str]) -> BpiResult<()>
364 where
365 T: DeserializeOwned,
366 {
367 for profile in profiles {
368 let Some(body) = local_probe_body(batch, endpoint, profile) else {
369 continue;
370 };
371
372 let _payload = serde_json::from_value::<ApiEnvelope<T>>(body)?.into_payload()?;
373 }
374
375 Ok(())
376 }
377
378 #[test]
379 fn user_client_borrows_root_client() -> Result<(), crate::BpiError> {
380 let client = BpiClient::new()?;
381 let user = client.user();
382
383 assert_eq!(
384 user.card_endpoint(),
385 "https://api.bilibili.com/x/web-interface/card"
386 );
387 Ok(())
388 }
389
390 #[test]
391 fn user_client_exposes_cards_endpoint() -> Result<(), crate::BpiError> {
392 let client = BpiClient::new()?;
393 let user = client.user();
394
395 assert_eq!(
396 user.cards_endpoint(),
397 "https://api.vc.bilibili.com/account/v1/user/cards"
398 );
399 Ok(())
400 }
401
402 #[test]
403 fn user_client_exposes_infos_endpoint() -> Result<(), crate::BpiError> {
404 let client = BpiClient::new()?;
405 let user = client.user();
406
407 assert_eq!(
408 user.infos_endpoint(),
409 "https://api.vc.bilibili.com/x/im/user_infos"
410 );
411 Ok(())
412 }
413
414 #[test]
415 fn user_client_exposes_space_info_endpoint() -> Result<(), crate::BpiError> {
416 let client = BpiClient::new()?;
417 let user = client.user();
418
419 assert_eq!(
420 user.space_info_endpoint(),
421 "https://api.bilibili.com/x/space/wbi/acc/info"
422 );
423 Ok(())
424 }
425
426 #[test]
427 fn user_client_exposes_space_notice_endpoint() -> Result<(), crate::BpiError> {
428 let client = BpiClient::new()?;
429 let user = client.user();
430
431 assert_eq!(
432 user.space_notice_endpoint(),
433 "https://api.bilibili.com/x/space/notice"
434 );
435 Ok(())
436 }
437
438 #[test]
439 fn user_client_exposes_bangumi_follow_list_endpoint() -> Result<(), crate::BpiError> {
440 let client = BpiClient::new()?;
441 let user = client.user();
442
443 assert_eq!(
444 user.bangumi_follow_list_endpoint(),
445 "https://api.bilibili.com/x/space/bangumi/follow/list"
446 );
447 Ok(())
448 }
449
450 #[test]
451 fn user_client_exposes_uploaded_videos_endpoint() -> Result<(), crate::BpiError> {
452 let client = BpiClient::new()?;
453 let user = client.user();
454
455 assert_eq!(
456 user.uploaded_videos_endpoint(),
457 "https://api.bilibili.com/x/space/wbi/arc/search"
458 );
459 Ok(())
460 }
461
462 #[test]
463 fn user_client_exposes_relation_stat_endpoint() -> Result<(), crate::BpiError> {
464 let client = BpiClient::new()?;
465 let user = client.user();
466
467 assert_eq!(
468 user.relation_stat_endpoint(),
469 "https://api.bilibili.com/x/relation/stat"
470 );
471 Ok(())
472 }
473
474 #[test]
475 fn user_client_exposes_followings_endpoint() -> Result<(), crate::BpiError> {
476 let client = BpiClient::new()?;
477 let user = client.user();
478
479 assert_eq!(
480 user.followings_endpoint(),
481 "https://api.bilibili.com/x/relation/followings"
482 );
483 Ok(())
484 }
485
486 #[test]
487 fn user_client_exposes_followers_endpoint() -> Result<(), crate::BpiError> {
488 let client = BpiClient::new()?;
489 let user = client.user();
490
491 assert_eq!(
492 user.followers_endpoint(),
493 "https://api.bilibili.com/x/relation/fans"
494 );
495 Ok(())
496 }
497
498 #[test]
499 fn user_client_exposes_follow_tags_endpoint() -> Result<(), crate::BpiError> {
500 let client = BpiClient::new()?;
501 let user = client.user();
502
503 assert_eq!(
504 user.follow_tags_endpoint(),
505 "https://api.bilibili.com/x/relation/tags"
506 );
507 Ok(())
508 }
509
510 #[test]
511 fn user_client_exposes_medal_wall_endpoint() -> Result<(), crate::BpiError> {
512 let client = BpiClient::new()?;
513 let user = client.user();
514
515 assert_eq!(
516 user.medal_wall_endpoint(),
517 "https://api.live.bilibili.com/xlive/web-ucenter/user/MedalWall"
518 );
519 Ok(())
520 }
521
522 #[test]
523 fn user_client_exposes_up_stat_endpoint() -> Result<(), crate::BpiError> {
524 let client = BpiClient::new()?;
525 let user = client.user();
526
527 assert_eq!(
528 user.up_stat_endpoint(),
529 "https://api.bilibili.com/x/space/upstat"
530 );
531 Ok(())
532 }
533
534 #[test]
535 fn user_client_exposes_nav_stat_endpoint() -> Result<(), crate::BpiError> {
536 let client = BpiClient::new()?;
537 let user = client.user();
538
539 assert_eq!(
540 user.nav_stat_endpoint(),
541 "https://api.bilibili.com/x/space/navnum"
542 );
543 Ok(())
544 }
545
546 #[test]
547 fn user_client_exposes_album_count_endpoint() -> Result<(), crate::BpiError> {
548 let client = BpiClient::new()?;
549 let user = client.user();
550
551 assert_eq!(
552 user.album_count_endpoint(),
553 "https://api.vc.bilibili.com/link_draw/v1/doc/upload_count"
554 );
555 Ok(())
556 }
557
558 #[test]
559 fn user_client_exposes_name_to_uid_endpoint() -> Result<(), crate::BpiError> {
560 let client = BpiClient::new()?;
561 let user = client.user();
562
563 assert_eq!(
564 user.name_to_uid_endpoint(),
565 "https://api.bilibili.com/x/polymer/web-dynamic/v1/name-to-uid"
566 );
567 Ok(())
568 }
569
570 #[test]
571 fn user_public_read_contracts_match_endpoint_requests() -> BpiResult<()> {
572 let expectations = [
573 (
574 "album-count",
575 "user.album_count",
576 ALBUM_COUNT_ENDPOINT,
577 "UserAlbumCount",
578 false,
579 ),
580 (
581 "bangumi-follow-list",
582 "user.bangumi_follow_list",
583 BANGUMI_FOLLOW_LIST_ENDPOINT,
584 "UserBangumiFollowList",
585 false,
586 ),
587 ("card", "user.card", CARD_ENDPOINT, "UserCardProfile", false),
588 (
589 "cards",
590 "user.cards",
591 CARDS_ENDPOINT,
592 "Vec<UserBatchCard>",
593 false,
594 ),
595 (
596 "infos",
597 "user.infos",
598 INFOS_ENDPOINT,
599 "Vec<UserBatchInfo>",
600 false,
601 ),
602 (
603 "medal-wall",
604 "user.medal_wall",
605 MEDAL_WALL_ENDPOINT,
606 "UserMedalWall",
607 false,
608 ),
609 (
610 "name-to-uid",
611 "user.name_to_uid",
612 NAME_TO_UID_ENDPOINT,
613 "UserNameToUid",
614 false,
615 ),
616 (
617 "nav-stat",
618 "user.nav_stat",
619 NAV_STAT_ENDPOINT,
620 "UserNavStat",
621 false,
622 ),
623 (
624 "relation-stat",
625 "user.relation_stat",
626 RELATION_STAT_ENDPOINT,
627 "UserRelationStat",
628 false,
629 ),
630 (
631 "space-info",
632 "user.space_info",
633 SPACE_INFO_ENDPOINT,
634 "UserSpaceProfile",
635 true,
636 ),
637 (
638 "space-notice",
639 "user.space_notice",
640 SPACE_NOTICE_ENDPOINT,
641 "UserSpaceNotice",
642 false,
643 ),
644 (
645 "up-stat",
646 "user.up_stat",
647 UP_STAT_ENDPOINT,
648 "UserUpStat",
649 false,
650 ),
651 (
652 "uploaded-videos",
653 "user.uploaded_videos",
654 UPLOADED_VIDEOS_ENDPOINT,
655 "UserUploadedVideos",
656 true,
657 ),
658 ];
659
660 for (endpoint, name, url, rust_model, requires_wbi) in expectations {
661 let contract = contract(endpoint)?;
662
663 assert_eq!(contract.name, name);
664 assert_eq!(contract.request.method, HttpMethod::Get);
665 assert_eq!(contract.request.url.as_str(), url);
666 assert_eq!(contract.request.auth.requires_wbi(), requires_wbi);
667 assert_eq!(contract.cases.len(), 3);
668 assert!(
669 contract
670 .cases
671 .iter()
672 .any(|case| case.response.rust_model.as_deref() == Some(rust_model)),
673 "{endpoint} should declare {rust_model} for at least one success case"
674 );
675 }
676
677 Ok(())
678 }
679
680 #[test]
681 fn user_relation_read_contracts_match_endpoint_requests() -> BpiResult<()> {
682 let expectations = [
683 (
684 "followings",
685 "user.followings",
686 FOLLOWINGS_ENDPOINT,
687 &[
688 ("order_type", "attention"),
689 ("pn", "1"),
690 ("ps", "20"),
691 ("vmid", "2"),
692 ][..],
693 "UserFollowings",
694 ),
695 (
696 "followers",
697 "user.followers",
698 FOLLOWERS_ENDPOINT,
699 &[("pn", "1"), ("ps", "20"), ("vmid", "2")][..],
700 "UserFollowers",
701 ),
702 (
703 "follow-tags",
704 "user.follow_tags",
705 FOLLOW_TAGS_ENDPOINT,
706 &[][..],
707 "Vec<UserFollowTag>",
708 ),
709 ];
710
711 for (endpoint, name, url, query_pairs, rust_model) in expectations {
712 let contract = contract(endpoint)?;
713
714 assert_eq!(contract.name, name);
715 assert_eq!(contract.request.method, HttpMethod::Get);
716 assert_eq!(contract.request.url.as_str(), url);
717 assert!(!contract.request.auth.requires_wbi());
718 assert_eq!(contract.cases.len(), 3);
719 assert!(
720 contract
721 .cases
722 .iter()
723 .any(|case| case.response.rust_model.as_deref() == Some(rust_model)),
724 "{endpoint} should declare {rust_model} for at least one success case"
725 );
726
727 for &(key, value) in query_pairs {
728 if !key.is_empty() {
729 assert_eq!(
730 contract.request.query.get(key).map(String::as_str),
731 Some(value)
732 );
733 }
734 }
735
736 let anonymous = contract
737 .cases
738 .iter()
739 .find(|case| case.name == "anonymous")
740 .ok_or_else(|| BpiError::unsupported_response("missing anonymous case"))?;
741 assert_eq!(anonymous.response.http_status, Some(200));
742 assert!(anonymous.response.api_code.is_some());
743 }
744
745 Ok(())
746 }
747
748 #[test]
749 fn user_public_read_response_fixtures_parse_declared_models() -> BpiResult<()> {
750 let album_count = ApiEnvelope::<UserAlbumCount>::from_slice(include_bytes!(
751 "../../tests/contracts/user/public-read/album-count/responses/success.json"
752 ))?
753 .into_payload()?;
754 let bangumi_follow_list =
755 ApiEnvelope::<UserBangumiFollowList>::from_slice(include_bytes!(
756 "../../tests/contracts/user/public-read/bangumi-follow-list/responses/success.json"
757 ))?
758 .into_payload()?;
759 let card = ApiEnvelope::<UserCardProfile>::from_slice(include_bytes!(
760 "../../tests/contracts/user/public-read/card/responses/success.json"
761 ))?
762 .into_payload()?;
763 let cards = ApiEnvelope::<Vec<UserBatchCard>>::from_slice(include_bytes!(
764 "../../tests/contracts/user/public-read/cards/responses/success.json"
765 ))?
766 .into_payload()?;
767 let infos = ApiEnvelope::<Vec<UserBatchInfo>>::from_slice(include_bytes!(
768 "../../tests/contracts/user/public-read/infos/responses/success.json"
769 ))?
770 .into_payload()?;
771 let medal_wall = ApiEnvelope::<UserMedalWall>::from_slice(include_bytes!(
772 "../../tests/contracts/user/public-read/medal-wall/responses/success.json"
773 ))?
774 .into_payload()?;
775 let name_to_uid = ApiEnvelope::<UserNameToUid>::from_slice(include_bytes!(
776 "../../tests/contracts/user/public-read/name-to-uid/responses/success.json"
777 ))?
778 .into_payload()?;
779 let nav_stat = ApiEnvelope::<UserNavStat>::from_slice(include_bytes!(
780 "../../tests/contracts/user/public-read/nav-stat/responses/success.json"
781 ))?
782 .into_payload()?;
783 let relation_stat = ApiEnvelope::<UserRelationStat>::from_slice(include_bytes!(
784 "../../tests/contracts/user/public-read/relation-stat/responses/success.json"
785 ))?
786 .into_payload()?;
787 let space_info = ApiEnvelope::<UserSpaceProfile>::from_slice(include_bytes!(
788 "../../tests/contracts/user/public-read/space-info/responses/success.json"
789 ))?
790 .into_payload()?;
791 let space_notice = ApiEnvelope::<UserSpaceNotice>::from_slice(include_bytes!(
792 "../../tests/contracts/user/public-read/space-notice/responses/success.json"
793 ))?
794 .into_payload()?;
795 let up_stat = ApiEnvelope::<UserUpStat>::from_slice(include_bytes!(
796 "../../tests/contracts/user/public-read/up-stat/responses/success.json"
797 ))?
798 .into_payload()?;
799 let uploaded_videos = ApiEnvelope::<UserUploadedVideos>::from_slice(include_bytes!(
800 "../../tests/contracts/user/public-read/uploaded-videos/responses/success.json"
801 ))?
802 .into_payload()?;
803
804 assert_eq!(album_count.all_count, 0);
805 assert!(bangumi_follow_list.items.is_empty());
806 assert_eq!(card.card.mid.get(), 2);
807 assert_eq!(cards.len(), 1);
808 assert_eq!(infos.len(), 1);
809 assert_eq!(medal_wall.uid.get(), 2);
810 assert_eq!(name_to_uid.uid_list.len(), 1);
811 assert_eq!(nav_stat.channel.master, 0);
812 assert_eq!(relation_stat.mid.get(), 2);
813 assert_eq!(space_info.mid.get(), 2);
814 assert_eq!(space_notice.content, "sanitized notice");
815 assert_eq!(up_stat.likes, 1);
816 assert!(uploaded_videos.list.videos.is_empty());
817 Ok(())
818 }
819
820 #[test]
821 fn user_relation_read_response_fixtures_parse_declared_models() -> BpiResult<()> {
822 let followings = ApiEnvelope::<UserFollowings>::from_slice(include_bytes!(
823 "../../tests/contracts/user/relation-read/followings/responses/success.json"
824 ))?
825 .into_payload()?;
826 let followers = ApiEnvelope::<UserFollowers>::from_slice(include_bytes!(
827 "../../tests/contracts/user/relation-read/followers/responses/success.json"
828 ))?
829 .into_payload()?;
830 let follow_tags = ApiEnvelope::<Vec<UserFollowTag>>::from_slice(include_bytes!(
831 "../../tests/contracts/user/relation-read/follow-tags/responses/success.json"
832 ))?
833 .into_payload()?;
834
835 assert_eq!(followings.list.len(), 1);
836 assert_eq!(followers.list.len(), 1);
837 assert_eq!(follow_tags.len(), 2);
838 Ok(())
839 }
840
841 #[test]
842 fn user_public_read_error_fixtures_preserve_observed_api_errors() -> BpiResult<()> {
843 for bytes in [
844 include_bytes!(
845 "../../tests/contracts/user/public-read/cards/responses/anonymous.error.json"
846 )
847 .as_slice(),
848 include_bytes!(
849 "../../tests/contracts/user/public-read/infos/responses/anonymous.error.json"
850 )
851 .as_slice(),
852 include_bytes!(
853 "../../tests/contracts/user/public-read/medal-wall/responses/anonymous.error.json"
854 )
855 .as_slice(),
856 include_bytes!(
857 "../../tests/contracts/user/public-read/name-to-uid/responses/anonymous.error.json"
858 )
859 .as_slice(),
860 ] {
861 let err = ApiEnvelope::<serde_json::Value>::from_slice(bytes)?
862 .ensure_success()
863 .unwrap_err();
864
865 assert!(err.requires_login());
866 }
867
868 let err = ApiEnvelope::<serde_json::Value>::from_slice(include_bytes!(
869 "../../tests/contracts/user/public-read/space-info/responses/anonymous.error.json"
870 ))?
871 .ensure_success()
872 .unwrap_err();
873 assert_eq!(err.code(), Some(-352));
874
875 let anonymous_up_stat = ApiEnvelope::<serde_json::Value>::from_slice(include_bytes!(
876 "../../tests/contracts/user/public-read/up-stat/responses/anonymous.empty.json"
877 ))?
878 .into_payload()?;
879 assert_eq!(anonymous_up_stat, serde_json::json!({}));
880
881 Ok(())
882 }
883
884 #[test]
885 fn user_relation_read_error_fixtures_preserve_observed_api_errors() -> BpiResult<()> {
886 for bytes in [
887 include_bytes!(
888 "../../tests/contracts/user/relation-read/followings/responses/anonymous.error.json"
889 )
890 .as_slice(),
891 include_bytes!(
892 "../../tests/contracts/user/relation-read/follow-tags/responses/anonymous.error.json"
893 )
894 .as_slice(),
895 ] {
896 let err = ApiEnvelope::<serde_json::Value>::from_slice(bytes)?
897 .ensure_success()
898 .unwrap_err();
899
900 assert!(err.requires_login());
901 }
902
903 let err = ApiEnvelope::<serde_json::Value>::from_slice(include_bytes!(
904 "../../tests/contracts/user/relation-read/followers/responses/anonymous.error.json"
905 ))?
906 .ensure_success()
907 .unwrap_err();
908 assert_eq!(err.code(), Some(-352));
909
910 Ok(())
911 }
912
913 #[test]
914 fn user_public_read_models_match_local_probe_outputs_when_available() -> BpiResult<()> {
915 parse_local_probe_outputs::<UserAlbumCount>(
916 "public-read",
917 "album-count",
918 &["anonymous", "normal", "vip"],
919 )?;
920 parse_local_probe_outputs::<UserBangumiFollowList>(
921 "public-read",
922 "bangumi-follow-list",
923 &["anonymous", "normal", "vip"],
924 )?;
925 parse_local_probe_outputs::<UserCardProfile>(
926 "public-read",
927 "card",
928 &["anonymous", "normal", "vip"],
929 )?;
930 parse_local_probe_outputs::<Vec<UserBatchCard>>(
931 "public-read",
932 "cards",
933 &["normal", "vip"],
934 )?;
935 parse_local_probe_outputs::<Vec<UserBatchInfo>>(
936 "public-read",
937 "infos",
938 &["normal", "vip"],
939 )?;
940 parse_local_probe_outputs::<UserMedalWall>(
941 "public-read",
942 "medal-wall",
943 &["normal", "vip"],
944 )?;
945 parse_local_probe_outputs::<UserNameToUid>(
946 "public-read",
947 "name-to-uid",
948 &["normal", "vip"],
949 )?;
950 parse_local_probe_outputs::<UserNavStat>(
951 "public-read",
952 "nav-stat",
953 &["anonymous", "normal", "vip"],
954 )?;
955 parse_local_probe_outputs::<UserRelationStat>(
956 "public-read",
957 "relation-stat",
958 &["anonymous", "normal", "vip"],
959 )?;
960 parse_local_probe_outputs::<UserSpaceProfile>(
961 "public-read",
962 "space-info",
963 &["normal", "vip"],
964 )?;
965 parse_local_probe_outputs::<UserSpaceNotice>(
966 "public-read",
967 "space-notice",
968 &["anonymous", "normal", "vip"],
969 )?;
970 parse_local_probe_outputs::<UserUpStat>("public-read", "up-stat", &["normal", "vip"])?;
971 parse_local_probe_outputs::<UserUploadedVideos>(
972 "public-read",
973 "uploaded-videos",
974 &["anonymous", "normal", "vip"],
975 )?;
976
977 if let Some(body) = local_probe_body("public-read", "up-stat", "anonymous") {
978 let payload =
979 serde_json::from_value::<ApiEnvelope<serde_json::Value>>(body)?.into_payload()?;
980 assert_eq!(payload, serde_json::json!({}));
981 }
982
983 Ok(())
984 }
985
986 #[test]
987 fn user_relation_read_models_match_local_probe_outputs_when_available() -> BpiResult<()> {
988 parse_local_probe_outputs::<UserFollowings>(
989 "relation-read",
990 "followings",
991 &["normal", "vip"],
992 )?;
993 parse_local_probe_outputs::<UserFollowers>(
994 "relation-read",
995 "followers",
996 &["normal", "vip"],
997 )?;
998 parse_local_probe_outputs::<Vec<UserFollowTag>>(
999 "relation-read",
1000 "follow-tags",
1001 &["normal", "vip"],
1002 )?;
1003
1004 for (endpoint, profile, code) in [
1005 ("followings", "anonymous", -101),
1006 ("followers", "anonymous", -352),
1007 ("follow-tags", "anonymous", -101),
1008 ] {
1009 let Some(body) = local_probe_body("relation-read", endpoint, profile) else {
1010 continue;
1011 };
1012 let err = serde_json::from_value::<ApiEnvelope<serde_json::Value>>(body)?
1013 .ensure_success()
1014 .unwrap_err();
1015
1016 assert_eq!(err.code(), Some(code));
1017 }
1018
1019 Ok(())
1020 }
1021
1022 #[test]
1023 fn user_client_methods_use_payload_request_helpers() {
1024 let source = include_str!("client.rs");
1025 let payload_helper = concat!(".send_", "bpi_payload");
1026 let legacy_envelope_helper = concat!(".send_", "bpi::<");
1027
1028 assert!(source.matches(payload_helper).count() >= 16);
1029 assert!(!source.contains(legacy_envelope_helper));
1030 }
1031}