Skip to main content

bpi_rs/user/
params.rs

1use crate::ids::Mid;
2use crate::{BpiError, BpiResult};
3
4/// Controls whether `/x/web-interface/card` should include the user's space header image.
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum UserCardPhoto {
7    /// Include the user's space header image when Bilibili returns it.
8    Include,
9    /// Exclude the user's space header image.
10    Exclude,
11}
12
13/// Parameters for `/x/web-interface/card`.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub struct UserCardParams {
16    mid: Mid,
17    photo: Option<UserCardPhoto>,
18}
19
20impl UserCardParams {
21    /// Creates card parameters for a validated user ID.
22    pub fn new(mid: Mid) -> Self {
23        Self { mid, photo: None }
24    }
25
26    /// Sets whether the response should include the user's space header image.
27    pub fn with_photo(mut self, photo: UserCardPhoto) -> Self {
28        self.photo = Some(photo);
29        self
30    }
31
32    pub(crate) fn query_pairs(&self) -> Vec<(&'static str, String)> {
33        let mut pairs = vec![("mid", self.mid.to_string())];
34
35        if let Some(photo) = self.photo {
36            let value = match photo {
37                UserCardPhoto::Include => "true",
38                UserCardPhoto::Exclude => "false",
39            };
40            pairs.push(("photo", value.to_string()));
41        }
42
43        pairs
44    }
45}
46
47/// Parameters for `/account/v1/user/cards`.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct UserCardsParams {
50    mids: Vec<Mid>,
51}
52
53impl UserCardsParams {
54    /// Creates batch-card parameters for one or more validated user IDs.
55    pub fn new<I>(mids: I) -> BpiResult<Self>
56    where
57        I: IntoIterator<Item = Mid>,
58    {
59        let mids = mids.into_iter().collect::<Vec<_>>();
60
61        if mids.is_empty() {
62            return Err(BpiError::invalid_parameter(
63                "uids",
64                "at least one user id is required",
65            ));
66        }
67
68        Ok(Self { mids })
69    }
70
71    pub(crate) fn query_pairs(&self) -> Vec<(&'static str, String)> {
72        vec![(
73            "uids",
74            self.mids
75                .iter()
76                .map(ToString::to_string)
77                .collect::<Vec<_>>()
78                .join(","),
79        )]
80    }
81}
82
83/// Parameters for `/x/im/user_infos`.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct UserInfosParams {
86    mids: Vec<Mid>,
87}
88
89impl UserInfosParams {
90    /// Creates batch-info parameters for one or more validated user IDs.
91    pub fn new<I>(mids: I) -> BpiResult<Self>
92    where
93        I: IntoIterator<Item = Mid>,
94    {
95        let mids = mids.into_iter().collect::<Vec<_>>();
96
97        if mids.is_empty() {
98            return Err(BpiError::invalid_parameter(
99                "uids",
100                "at least one user id is required",
101            ));
102        }
103
104        Ok(Self { mids })
105    }
106
107    pub(crate) fn query_pairs(&self) -> Vec<(&'static str, String)> {
108        vec![(
109            "uids",
110            self.mids
111                .iter()
112                .map(ToString::to_string)
113                .collect::<Vec<_>>()
114                .join(","),
115        )]
116    }
117}
118
119/// Parameters for `/x/space/wbi/acc/info`.
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub struct UserSpaceParams {
122    mid: Mid,
123}
124
125impl UserSpaceParams {
126    /// Creates space-info parameters for a validated user ID.
127    pub fn new(mid: Mid) -> Self {
128        Self { mid }
129    }
130
131    pub(crate) fn query_pairs(&self) -> Vec<(&'static str, String)> {
132        vec![("mid", self.mid.to_string())]
133    }
134}
135
136/// Parameters for `/x/space/notice`.
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub struct UserSpaceNoticeParams {
139    mid: Mid,
140}
141
142impl UserSpaceNoticeParams {
143    /// Creates space-notice parameters for a validated user ID.
144    pub fn new(mid: Mid) -> Self {
145        Self { mid }
146    }
147
148    pub(crate) fn query_pairs(&self) -> Vec<(&'static str, String)> {
149        vec![("mid", self.mid.to_string())]
150    }
151}
152
153/// Follow-list category for `/x/space/bangumi/follow/list`.
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub enum UserBangumiFollowKind {
156    /// Followed bangumi/anime seasons.
157    Bangumi,
158    /// Followed cinema/drama seasons.
159    Cinema,
160}
161
162impl UserBangumiFollowKind {
163    fn as_query_value(self) -> &'static str {
164        match self {
165            Self::Bangumi => "1",
166            Self::Cinema => "2",
167        }
168    }
169}
170
171/// Parameters for `/x/space/bangumi/follow/list`.
172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
173pub struct UserBangumiFollowListParams {
174    mid: Mid,
175    kind: UserBangumiFollowKind,
176    page: u32,
177    page_size: u32,
178}
179
180impl UserBangumiFollowListParams {
181    /// Creates bangumi follow-list parameters for a validated user ID.
182    pub fn new(mid: Mid) -> Self {
183        Self {
184            mid,
185            kind: UserBangumiFollowKind::Bangumi,
186            page: 1,
187            page_size: 15,
188        }
189    }
190
191    /// Sets whether to fetch followed bangumi or cinema seasons.
192    pub fn with_kind(mut self, kind: UserBangumiFollowKind) -> Self {
193        self.kind = kind;
194        self
195    }
196
197    /// Sets the 1-based page number.
198    pub fn with_page(mut self, page: u32) -> Self {
199        self.page = page;
200        self
201    }
202
203    /// Sets the page size. Bilibili accepts values from 1 to 30.
204    pub fn with_page_size(mut self, page_size: u32) -> BpiResult<Self> {
205        if !(1..=30).contains(&page_size) {
206            return Err(BpiError::invalid_parameter(
207                "page_size",
208                "page size must be between 1 and 30",
209            ));
210        }
211
212        self.page_size = page_size;
213        Ok(self)
214    }
215
216    pub(crate) fn query_pairs(&self) -> Vec<(&'static str, String)> {
217        vec![
218            ("vmid", self.mid.to_string()),
219            ("type", self.kind.as_query_value().to_string()),
220            ("pn", self.page.to_string()),
221            ("ps", self.page_size.to_string()),
222        ]
223    }
224}
225
226/// Parameters for `/x/relation/stat`.
227#[derive(Debug, Clone, Copy, PartialEq, Eq)]
228pub struct UserRelationStatParams {
229    mid: Mid,
230}
231
232impl UserRelationStatParams {
233    /// Creates relation-stat parameters for a validated user ID.
234    pub fn new(mid: Mid) -> Self {
235        Self { mid }
236    }
237
238    pub(crate) fn query_pairs(&self) -> Vec<(&'static str, String)> {
239        vec![("vmid", self.mid.to_string())]
240    }
241}
242
243/// Parameters for `/x/relation/followings`.
244#[derive(Debug, Clone, PartialEq, Eq)]
245pub struct UserFollowingsParams {
246    mid: Mid,
247    order_type: Option<String>,
248    page_size: Option<u32>,
249    page: Option<u32>,
250}
251
252impl UserFollowingsParams {
253    /// Creates following-list parameters for a validated user ID.
254    pub fn new(mid: Mid) -> Self {
255        Self {
256            mid,
257            order_type: None,
258            page_size: None,
259            page: None,
260        }
261    }
262
263    /// Sets the raw Bilibili order type, such as `attention`.
264    pub fn with_order_type(mut self, order_type: impl Into<String>) -> Self {
265        let order_type = order_type.into();
266        if !order_type.trim().is_empty() {
267            self.order_type = Some(order_type);
268        }
269        self
270    }
271
272    /// Sets the page size.
273    pub fn with_page_size(mut self, page_size: u32) -> Self {
274        self.page_size = Some(page_size);
275        self
276    }
277
278    /// Sets the 1-based page number.
279    pub fn with_page(mut self, page: u32) -> Self {
280        self.page = Some(page);
281        self
282    }
283
284    pub(crate) fn query_pairs(&self) -> Vec<(&'static str, String)> {
285        let mut pairs = vec![("vmid", self.mid.to_string())];
286
287        if let Some(order_type) = &self.order_type {
288            pairs.push(("order_type", order_type.to_string()));
289        }
290        if let Some(page_size) = self.page_size {
291            pairs.push(("ps", page_size.to_string()));
292        }
293        if let Some(page) = self.page {
294            pairs.push(("pn", page.to_string()));
295        }
296
297        pairs
298    }
299}
300
301/// Parameters for `/x/relation/fans`.
302#[derive(Debug, Clone, PartialEq, Eq)]
303pub struct UserFollowersParams {
304    mid: Mid,
305    page_size: Option<u32>,
306    page: Option<u32>,
307    offset: Option<String>,
308    last_access_ts: Option<u64>,
309    from: Option<String>,
310}
311
312impl UserFollowersParams {
313    /// Creates follower-list parameters for a validated user ID.
314    pub fn new(mid: Mid) -> Self {
315        Self {
316            mid,
317            page_size: None,
318            page: None,
319            offset: None,
320            last_access_ts: None,
321            from: None,
322        }
323    }
324
325    /// Sets the page size.
326    pub fn with_page_size(mut self, page_size: u32) -> Self {
327        self.page_size = Some(page_size);
328        self
329    }
330
331    /// Sets the 1-based page number.
332    pub fn with_page(mut self, page: u32) -> Self {
333        self.page = Some(page);
334        self
335    }
336
337    /// Sets the pagination offset returned by Bilibili.
338    pub fn with_offset(mut self, offset: impl Into<String>) -> Self {
339        let offset = offset.into();
340        if !offset.trim().is_empty() {
341            self.offset = Some(offset);
342        }
343        self
344    }
345
346    /// Sets the last-access timestamp in seconds.
347    pub fn with_last_access_ts(mut self, last_access_ts: u64) -> Self {
348        self.last_access_ts = Some(last_access_ts);
349        self
350    }
351
352    /// Sets the raw Bilibili source marker, such as `main`.
353    pub fn with_from(mut self, from: impl Into<String>) -> Self {
354        let from = from.into();
355        if !from.trim().is_empty() {
356            self.from = Some(from);
357        }
358        self
359    }
360
361    pub(crate) fn query_pairs(&self) -> Vec<(&'static str, String)> {
362        let mut pairs = vec![("vmid", self.mid.to_string())];
363
364        if let Some(page_size) = self.page_size {
365            pairs.push(("ps", page_size.to_string()));
366        }
367        if let Some(page) = self.page {
368            pairs.push(("pn", page.to_string()));
369        }
370        if let Some(offset) = &self.offset {
371            pairs.push(("offset", offset.to_string()));
372        }
373        if let Some(last_access_ts) = self.last_access_ts {
374            pairs.push(("last_access_ts", last_access_ts.to_string()));
375        }
376        if let Some(from) = &self.from {
377            pairs.push(("from", from.to_string()));
378        }
379
380        pairs
381    }
382}
383
384/// Parameters for `/xlive/web-ucenter/user/MedalWall`.
385#[derive(Debug, Clone, Copy, PartialEq, Eq)]
386pub struct UserMedalWallParams {
387    target_id: Mid,
388}
389
390impl UserMedalWallParams {
391    /// Creates medal-wall parameters for a validated user ID.
392    pub fn new(target_id: Mid) -> Self {
393        Self { target_id }
394    }
395
396    pub(crate) fn query_pairs(&self) -> Vec<(&'static str, String)> {
397        vec![("target_id", self.target_id.to_string())]
398    }
399}
400
401/// Parameters for `/x/space/upstat`.
402#[derive(Debug, Clone, Copy, PartialEq, Eq)]
403pub struct UserUpStatParams {
404    mid: Mid,
405}
406
407impl UserUpStatParams {
408    /// Creates up-stat parameters for a validated user ID.
409    pub fn new(mid: Mid) -> Self {
410        Self { mid }
411    }
412
413    pub(crate) fn query_pairs(&self) -> Vec<(&'static str, String)> {
414        vec![("mid", self.mid.to_string())]
415    }
416}
417
418/// Parameters for `/x/space/navnum`.
419#[derive(Debug, Clone, Copy, PartialEq, Eq)]
420pub struct UserNavStatParams {
421    mid: Mid,
422}
423
424impl UserNavStatParams {
425    /// Creates nav-stat parameters for a validated user ID.
426    pub fn new(mid: Mid) -> Self {
427        Self { mid }
428    }
429
430    pub(crate) fn query_pairs(&self) -> Vec<(&'static str, String)> {
431        vec![("mid", self.mid.to_string())]
432    }
433}
434
435/// Parameters for `/link_draw/v1/doc/upload_count`.
436#[derive(Debug, Clone, Copy, PartialEq, Eq)]
437pub struct UserAlbumCountParams {
438    mid: Mid,
439}
440
441impl UserAlbumCountParams {
442    /// Creates album-count parameters for a validated user ID.
443    pub fn new(mid: Mid) -> Self {
444        Self { mid }
445    }
446
447    pub(crate) fn query_pairs(&self) -> Vec<(&'static str, String)> {
448        vec![("uid", self.mid.to_string())]
449    }
450}
451
452/// Parameters for `/x/polymer/web-dynamic/v1/name-to-uid`.
453#[derive(Debug, Clone, PartialEq, Eq)]
454pub struct UserNameToUidParams {
455    names: Vec<String>,
456}
457
458impl UserNameToUidParams {
459    /// Creates name-to-UID parameters from one or more non-blank display names.
460    pub fn new<I, S>(names: I) -> BpiResult<Self>
461    where
462        I: IntoIterator<Item = S>,
463        S: Into<String>,
464    {
465        let names = names
466            .into_iter()
467            .map(Into::into)
468            .map(|name| name.trim().to_string())
469            .collect::<Vec<_>>();
470
471        if names.is_empty() {
472            return Err(BpiError::invalid_parameter(
473                "names",
474                "at least one name is required",
475            ));
476        }
477
478        if names.iter().any(|name| name.is_empty()) {
479            return Err(BpiError::invalid_parameter(
480                "names",
481                "names cannot contain blank values",
482            ));
483        }
484
485        Ok(Self { names })
486    }
487
488    pub(crate) fn query_pairs(&self) -> Vec<(&'static str, String)> {
489        vec![("names", self.names.join(","))]
490    }
491}
492
493/// Sort order for uploaded videos in a user's space.
494#[derive(Debug, Clone, Copy, PartialEq, Eq)]
495pub enum UserUploadedVideoOrder {
496    /// Sort by publish time.
497    Pubdate,
498    /// Sort by view count.
499    Click,
500    /// Sort by favorite count.
501    Stow,
502}
503
504impl UserUploadedVideoOrder {
505    fn as_query_value(self) -> &'static str {
506        match self {
507            Self::Pubdate => "pubdate",
508            Self::Click => "click",
509            Self::Stow => "stow",
510        }
511    }
512}
513
514impl TryFrom<&str> for UserUploadedVideoOrder {
515    type Error = BpiError;
516
517    fn try_from(value: &str) -> BpiResult<Self> {
518        match value.trim() {
519            "pubdate" => Ok(Self::Pubdate),
520            "click" => Ok(Self::Click),
521            "stow" => Ok(Self::Stow),
522            _ => Err(BpiError::invalid_parameter(
523                "order",
524                "uploaded video order must be pubdate, click, or stow",
525            )),
526        }
527    }
528}
529
530/// Parameters for `/x/space/wbi/arc/search`.
531#[derive(Debug, Clone, PartialEq, Eq)]
532pub struct UserUploadedVideosParams {
533    mid: Mid,
534    order: UserUploadedVideoOrder,
535    tid: u64,
536    keyword: Option<String>,
537    page: u32,
538    page_size: u32,
539}
540
541impl UserUploadedVideosParams {
542    /// Creates uploaded-video parameters for a validated user ID.
543    pub fn new(mid: Mid) -> Self {
544        Self {
545            mid,
546            order: UserUploadedVideoOrder::Pubdate,
547            tid: 0,
548            keyword: None,
549            page: 1,
550            page_size: 30,
551        }
552    }
553
554    /// Sets the video sort order.
555    pub fn with_order(mut self, order: UserUploadedVideoOrder) -> Self {
556        self.order = order;
557        self
558    }
559
560    /// Sets the partition filter. `0` means all partitions.
561    pub fn with_tid(mut self, tid: u64) -> Self {
562        self.tid = tid;
563        self
564    }
565
566    /// Sets a keyword filter.
567    pub fn with_keyword(mut self, keyword: impl Into<String>) -> Self {
568        let keyword = keyword.into().trim().to_string();
569        if !keyword.is_empty() {
570            self.keyword = Some(keyword);
571        }
572        self
573    }
574
575    /// Sets the 1-based page number.
576    pub fn with_page(mut self, page: u32) -> BpiResult<Self> {
577        if page == 0 {
578            return Err(BpiError::invalid_parameter(
579                "page",
580                "page number must be at least 1",
581            ));
582        }
583
584        self.page = page;
585        Ok(self)
586    }
587
588    /// Sets the page size.
589    pub fn with_page_size(mut self, page_size: u32) -> BpiResult<Self> {
590        if page_size == 0 {
591            return Err(BpiError::invalid_parameter(
592                "page_size",
593                "page size must be at least 1",
594            ));
595        }
596
597        self.page_size = page_size;
598        Ok(self)
599    }
600
601    pub(crate) fn query_pairs(&self) -> Vec<(&'static str, String)> {
602        let mut pairs = vec![
603            ("mid", self.mid.to_string()),
604            ("order", self.order.as_query_value().to_string()),
605            ("tid", self.tid.to_string()),
606            ("pn", self.page.to_string()),
607            ("ps", self.page_size.to_string()),
608        ];
609
610        if let Some(keyword) = &self.keyword {
611            pairs.push(("keyword", keyword.to_string()));
612        }
613
614        pairs
615    }
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621
622    #[test]
623    fn user_card_params_serializes_mid_query() -> Result<(), BpiError> {
624        let params = UserCardParams::new(Mid::new(1001)?);
625
626        assert_eq!(params.query_pairs(), vec![("mid", "1001".to_string())]);
627        Ok(())
628    }
629
630    #[test]
631    fn user_card_params_serializes_include_photo_query() -> Result<(), BpiError> {
632        let params = UserCardParams::new(Mid::new(1001)?).with_photo(UserCardPhoto::Include);
633
634        assert_eq!(
635            params.query_pairs(),
636            vec![("mid", "1001".to_string()), ("photo", "true".to_string())]
637        );
638        Ok(())
639    }
640
641    #[test]
642    fn user_card_params_serializes_exclude_photo_query() -> Result<(), BpiError> {
643        let params = UserCardParams::new(Mid::new(1001)?).with_photo(UserCardPhoto::Exclude);
644
645        assert_eq!(
646            params.query_pairs(),
647            vec![("mid", "1001".to_string()), ("photo", "false".to_string())]
648        );
649        Ok(())
650    }
651
652    #[test]
653    fn user_cards_params_serializes_uids_query() -> Result<(), BpiError> {
654        let params = UserCardsParams::new([Mid::new(1001)?, Mid::new(1002)?])?;
655
656        assert_eq!(
657            params.query_pairs(),
658            vec![("uids", "1001,1002".to_string())]
659        );
660        Ok(())
661    }
662
663    #[test]
664    fn user_cards_params_rejects_empty_uid_list() {
665        let err = UserCardsParams::new([]).unwrap_err();
666
667        assert!(matches!(
668            err,
669            BpiError::InvalidParameter { field: "uids", .. }
670        ));
671    }
672
673    #[test]
674    fn user_infos_params_serializes_uids_query() -> Result<(), BpiError> {
675        let params = UserInfosParams::new([Mid::new(1001)?, Mid::new(1002)?])?;
676
677        assert_eq!(
678            params.query_pairs(),
679            vec![("uids", "1001,1002".to_string())]
680        );
681        Ok(())
682    }
683
684    #[test]
685    fn user_infos_params_rejects_empty_uid_list() {
686        let err = UserInfosParams::new([]).unwrap_err();
687
688        assert!(matches!(
689            err,
690            BpiError::InvalidParameter { field: "uids", .. }
691        ));
692    }
693
694    #[test]
695    fn user_space_params_serializes_mid_query() -> Result<(), BpiError> {
696        let params = UserSpaceParams::new(Mid::new(1001)?);
697
698        assert_eq!(params.query_pairs(), vec![("mid", "1001".to_string())]);
699        Ok(())
700    }
701
702    #[test]
703    fn user_space_notice_params_serializes_mid_query() -> Result<(), BpiError> {
704        let params = UserSpaceNoticeParams::new(Mid::new(1001)?);
705
706        assert_eq!(params.query_pairs(), vec![("mid", "1001".to_string())]);
707        Ok(())
708    }
709
710    #[test]
711    fn user_bangumi_follow_list_params_serializes_default_query() -> Result<(), BpiError> {
712        let params = UserBangumiFollowListParams::new(Mid::new(1001)?);
713
714        assert_eq!(
715            params.query_pairs(),
716            vec![
717                ("vmid", "1001".to_string()),
718                ("type", "1".to_string()),
719                ("pn", "1".to_string()),
720                ("ps", "15".to_string()),
721            ]
722        );
723        Ok(())
724    }
725
726    #[test]
727    fn user_bangumi_follow_list_params_serializes_optional_filters() -> Result<(), BpiError> {
728        let params = UserBangumiFollowListParams::new(Mid::new(1001)?)
729            .with_kind(UserBangumiFollowKind::Cinema)
730            .with_page(2)
731            .with_page_size(30)?;
732
733        assert_eq!(
734            params.query_pairs(),
735            vec![
736                ("vmid", "1001".to_string()),
737                ("type", "2".to_string()),
738                ("pn", "2".to_string()),
739                ("ps", "30".to_string()),
740            ]
741        );
742        Ok(())
743    }
744
745    #[test]
746    fn user_bangumi_follow_list_params_rejects_zero_page_size() -> Result<(), BpiError> {
747        let err = UserBangumiFollowListParams::new(Mid::new(1001)?)
748            .with_page_size(0)
749            .unwrap_err();
750
751        assert!(matches!(
752            err,
753            BpiError::InvalidParameter {
754                field: "page_size",
755                ..
756            }
757        ));
758        Ok(())
759    }
760
761    #[test]
762    fn user_bangumi_follow_list_params_rejects_large_page_size() -> Result<(), BpiError> {
763        let err = UserBangumiFollowListParams::new(Mid::new(1001)?)
764            .with_page_size(31)
765            .unwrap_err();
766
767        assert!(matches!(
768            err,
769            BpiError::InvalidParameter {
770                field: "page_size",
771                ..
772            }
773        ));
774        Ok(())
775    }
776
777    #[test]
778    fn user_uploaded_videos_params_serializes_default_query() -> Result<(), BpiError> {
779        let params = UserUploadedVideosParams::new(Mid::new(1001)?);
780
781        assert_eq!(
782            params.query_pairs(),
783            vec![
784                ("mid", "1001".to_string()),
785                ("order", "pubdate".to_string()),
786                ("tid", "0".to_string()),
787                ("pn", "1".to_string()),
788                ("ps", "30".to_string()),
789            ]
790        );
791        Ok(())
792    }
793
794    #[test]
795    fn user_uploaded_video_order_parses_supported_values() -> Result<(), BpiError> {
796        assert_eq!(
797            UserUploadedVideoOrder::try_from("pubdate")?,
798            UserUploadedVideoOrder::Pubdate
799        );
800        assert_eq!(
801            UserUploadedVideoOrder::try_from("click")?,
802            UserUploadedVideoOrder::Click
803        );
804        assert_eq!(
805            UserUploadedVideoOrder::try_from("stow")?,
806            UserUploadedVideoOrder::Stow
807        );
808        Ok(())
809    }
810
811    #[test]
812    fn user_uploaded_video_order_rejects_unknown_value() {
813        let err = UserUploadedVideoOrder::try_from("invalid").unwrap_err();
814
815        assert!(matches!(
816            err,
817            BpiError::InvalidParameter { field: "order", .. }
818        ));
819    }
820
821    #[test]
822    fn user_uploaded_videos_params_serializes_optional_filters() -> Result<(), BpiError> {
823        let params = UserUploadedVideosParams::new(Mid::new(1001)?)
824            .with_order(UserUploadedVideoOrder::Click)
825            .with_tid(33)
826            .with_keyword("rust")
827            .with_page(2)?
828            .with_page_size(20)?;
829
830        assert_eq!(
831            params.query_pairs(),
832            vec![
833                ("mid", "1001".to_string()),
834                ("order", "click".to_string()),
835                ("tid", "33".to_string()),
836                ("pn", "2".to_string()),
837                ("ps", "20".to_string()),
838                ("keyword", "rust".to_string()),
839            ]
840        );
841        Ok(())
842    }
843
844    #[test]
845    fn user_uploaded_videos_params_trims_keyword() -> Result<(), BpiError> {
846        let params = UserUploadedVideosParams::new(Mid::new(1001)?).with_keyword("  rust  ");
847
848        assert_eq!(
849            params.query_pairs(),
850            vec![
851                ("mid", "1001".to_string()),
852                ("order", "pubdate".to_string()),
853                ("tid", "0".to_string()),
854                ("pn", "1".to_string()),
855                ("ps", "30".to_string()),
856                ("keyword", "rust".to_string()),
857            ]
858        );
859        Ok(())
860    }
861
862    #[test]
863    fn user_uploaded_videos_params_ignores_blank_keyword() -> Result<(), BpiError> {
864        let params = UserUploadedVideosParams::new(Mid::new(1001)?).with_keyword("   ");
865
866        assert_eq!(
867            params.query_pairs(),
868            vec![
869                ("mid", "1001".to_string()),
870                ("order", "pubdate".to_string()),
871                ("tid", "0".to_string()),
872                ("pn", "1".to_string()),
873                ("ps", "30".to_string()),
874            ]
875        );
876        Ok(())
877    }
878
879    #[test]
880    fn user_uploaded_videos_params_rejects_zero_page() -> Result<(), BpiError> {
881        let err = UserUploadedVideosParams::new(Mid::new(1001)?)
882            .with_page(0)
883            .unwrap_err();
884
885        assert!(matches!(
886            err,
887            BpiError::InvalidParameter { field: "page", .. }
888        ));
889        Ok(())
890    }
891
892    #[test]
893    fn user_uploaded_videos_params_rejects_zero_page_size() -> Result<(), BpiError> {
894        let err = UserUploadedVideosParams::new(Mid::new(1001)?)
895            .with_page_size(0)
896            .unwrap_err();
897
898        assert!(matches!(
899            err,
900            BpiError::InvalidParameter {
901                field: "page_size",
902                ..
903            }
904        ));
905        Ok(())
906    }
907
908    #[test]
909    fn user_relation_stat_params_serializes_mid_query() -> Result<(), BpiError> {
910        let params = UserRelationStatParams::new(Mid::new(1001)?);
911
912        assert_eq!(params.query_pairs(), vec![("vmid", "1001".to_string())]);
913        Ok(())
914    }
915
916    #[test]
917    fn user_followings_params_serializes_default_query() -> Result<(), BpiError> {
918        let params = UserFollowingsParams::new(Mid::new(1001)?);
919
920        assert_eq!(params.query_pairs(), vec![("vmid", "1001".to_string())]);
921        Ok(())
922    }
923
924    #[test]
925    fn user_followings_params_serializes_optional_filters() -> Result<(), BpiError> {
926        let params = UserFollowingsParams::new(Mid::new(1001)?)
927            .with_order_type("attention")
928            .with_page_size(20)
929            .with_page(2);
930
931        assert_eq!(
932            params.query_pairs(),
933            vec![
934                ("vmid", "1001".to_string()),
935                ("order_type", "attention".to_string()),
936                ("ps", "20".to_string()),
937                ("pn", "2".to_string()),
938            ]
939        );
940        Ok(())
941    }
942
943    #[test]
944    fn user_followers_params_serializes_default_query() -> Result<(), BpiError> {
945        let params = UserFollowersParams::new(Mid::new(1001)?);
946
947        assert_eq!(params.query_pairs(), vec![("vmid", "1001".to_string())]);
948        Ok(())
949    }
950
951    #[test]
952    fn user_followers_params_serializes_optional_filters() -> Result<(), BpiError> {
953        let params = UserFollowersParams::new(Mid::new(1001)?)
954            .with_page_size(20)
955            .with_page(2)
956            .with_offset("next-offset")
957            .with_last_access_ts(1_700_000_000)
958            .with_from("main");
959
960        assert_eq!(
961            params.query_pairs(),
962            vec![
963                ("vmid", "1001".to_string()),
964                ("ps", "20".to_string()),
965                ("pn", "2".to_string()),
966                ("offset", "next-offset".to_string()),
967                ("last_access_ts", "1700000000".to_string()),
968                ("from", "main".to_string()),
969            ]
970        );
971        Ok(())
972    }
973
974    #[test]
975    fn user_medal_wall_params_serializes_target_id_query() -> Result<(), BpiError> {
976        let params = UserMedalWallParams::new(Mid::new(1001)?);
977
978        assert_eq!(
979            params.query_pairs(),
980            vec![("target_id", "1001".to_string())]
981        );
982        Ok(())
983    }
984
985    #[test]
986    fn user_up_stat_params_serializes_mid_query() -> Result<(), BpiError> {
987        let params = UserUpStatParams::new(Mid::new(1001)?);
988
989        assert_eq!(params.query_pairs(), vec![("mid", "1001".to_string())]);
990        Ok(())
991    }
992
993    #[test]
994    fn user_nav_stat_params_serializes_mid_query() -> Result<(), BpiError> {
995        let params = UserNavStatParams::new(Mid::new(1001)?);
996
997        assert_eq!(params.query_pairs(), vec![("mid", "1001".to_string())]);
998        Ok(())
999    }
1000
1001    #[test]
1002    fn user_album_count_params_serializes_uid_query() -> Result<(), BpiError> {
1003        let params = UserAlbumCountParams::new(Mid::new(1001)?);
1004
1005        assert_eq!(params.query_pairs(), vec![("uid", "1001".to_string())]);
1006        Ok(())
1007    }
1008
1009    #[test]
1010    fn user_name_to_uid_params_serializes_joined_names_query() -> Result<(), BpiError> {
1011        let params = UserNameToUidParams::new(["fixture_user", "another_user"])?;
1012
1013        assert_eq!(
1014            params.query_pairs(),
1015            vec![("names", "fixture_user,another_user".to_string())]
1016        );
1017        Ok(())
1018    }
1019
1020    #[test]
1021    fn user_name_to_uid_params_rejects_empty_names() {
1022        let err = UserNameToUidParams::new(Vec::<&str>::new()).unwrap_err();
1023
1024        assert!(matches!(
1025            err,
1026            BpiError::InvalidParameter { field: "names", .. }
1027        ));
1028    }
1029
1030    #[test]
1031    fn user_name_to_uid_params_rejects_blank_name() {
1032        let err = UserNameToUidParams::new(["fixture_user", "  "]).unwrap_err();
1033
1034        assert!(matches!(
1035            err,
1036            BpiError::InvalidParameter { field: "names", .. }
1037        ));
1038    }
1039}