Skip to main content

bpi_rs/user/
client.rs

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/// User domain API client.
33#[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    /// Fetches public user card information.
124    pub async fn card(&self, params: UserCardParams) -> BpiResult<UserCardProfile> {
125        self.client
126            .get(CARD_ENDPOINT)
127            .query(&params.query_pairs())
128            .send_bpi_payload("user.card")
129            .await
130    }
131
132    /// Fetches compact public card information for one or more users.
133    pub async fn cards(&self, params: UserCardsParams) -> BpiResult<Vec<UserBatchCard>> {
134        self.client
135            .get(CARDS_ENDPOINT)
136            .query(&params.query_pairs())
137            .send_bpi_payload("user.cards")
138            .await
139    }
140
141    /// Fetches detailed public batch information for one or more users.
142    pub async fn infos(&self, params: UserInfosParams) -> BpiResult<Vec<UserBatchInfo>> {
143        self.client
144            .get(INFOS_ENDPOINT)
145            .query(&params.query_pairs())
146            .send_bpi_payload("user.infos")
147            .await
148    }
149
150    /// Fetches public album submission counters for a user.
151    pub async fn album_count(&self, params: UserAlbumCountParams) -> BpiResult<UserAlbumCount> {
152        self.client
153            .get(ALBUM_COUNT_ENDPOINT)
154            .query(&params.query_pairs())
155            .send_bpi_payload("user.album_count")
156            .await
157    }
158
159    /// Fetches followed bangumi or cinema seasons for a public user.
160    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(&params.query_pairs())
168            .send_bpi_payload("user.bangumi_follow_list")
169            .await
170    }
171
172    /// Fetches users followed by a public member.
173    pub async fn followings(&self, params: UserFollowingsParams) -> BpiResult<UserFollowings> {
174        self.client
175            .get(FOLLOWINGS_ENDPOINT)
176            .with_bilibili_headers()
177            .query(&params.query_pairs())
178            .send_bpi_payload("user.followings")
179            .await
180    }
181
182    /// Fetches users following a public member.
183    pub async fn followers(&self, params: UserFollowersParams) -> BpiResult<UserFollowers> {
184        self.client
185            .get(FOLLOWERS_ENDPOINT)
186            .with_bilibili_headers()
187            .query(&params.query_pairs())
188            .send_bpi_payload("user.followers")
189            .await
190    }
191
192    /// Fetches follow groups for the current authenticated session.
193    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    /// Fetches a public fan-medal wall for a user.
202    pub async fn medal_wall(&self, params: UserMedalWallParams) -> BpiResult<UserMedalWall> {
203        self.client
204            .get(MEDAL_WALL_ENDPOINT)
205            .with_bilibili_headers()
206            .query(&params.query_pairs())
207            .send_bpi_payload("user.medal_wall")
208            .await
209    }
210
211    /// Looks up member IDs by public display names.
212    pub async fn name_to_uid(&self, params: UserNameToUidParams) -> BpiResult<UserNameToUid> {
213        self.client
214            .get(NAME_TO_UID_ENDPOINT)
215            .query(&params.query_pairs())
216            .send_bpi_payload("user.name_to_uid")
217            .await
218    }
219
220    /// Fetches public relation counts for a user.
221    pub async fn relation_stat(
222        &self,
223        params: UserRelationStatParams,
224    ) -> BpiResult<UserRelationStat> {
225        self.client
226            .get(RELATION_STAT_ENDPOINT)
227            .query(&params.query_pairs())
228            .send_bpi_payload("user.relation_stat")
229            .await
230    }
231
232    /// Fetches public space navigation counters for a user.
233    pub async fn nav_stat(&self, params: UserNavStatParams) -> BpiResult<UserNavStat> {
234        self.client
235            .get(NAV_STAT_ENDPOINT)
236            .query(&params.query_pairs())
237            .send_bpi_payload("user.nav_stat")
238            .await
239    }
240
241    /// Fetches public creator statistics for a user.
242    pub async fn up_stat(&self, params: UserUpStatParams) -> BpiResult<UserUpStat> {
243        self.client
244            .get(UP_STAT_ENDPOINT)
245            .query(&params.query_pairs())
246            .send_bpi_payload("user.up_stat")
247            .await
248    }
249
250    /// Fetches public user space information.
251    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    /// Fetches the public space notice for a user.
262    pub async fn space_notice(&self, params: UserSpaceNoticeParams) -> BpiResult<UserSpaceNotice> {
263        self.client
264            .get(SPACE_NOTICE_ENDPOINT)
265            .query(&params.query_pairs())
266            .send_bpi_payload("user.space_notice")
267            .await
268    }
269
270    /// Fetches videos uploaded to a user's public space.
271    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}