redmine_api/api/
users.rs

1//! Users Rest API Endpoint definitions
2//!
3//! [Redmine Documentation](https://www.redmine.org/projects/redmine/wiki/Rest_Users)
4//!
5//! - [x] all users endpoint
6//!   - [x] status filter
7//!   - [x] name filter
8//!   - [x] group_id filter
9//! - [x] specific user endpoint
10//!   - [x] by user id
11//!   - [x] current
12//! - [x] create user endpoint
13//! - [x] update user endpoint
14//! - [x] delete user endpoint
15
16use derive_builder::Builder;
17use reqwest::Method;
18use std::borrow::Cow;
19
20use crate::api::custom_fields::CustomFieldEssentialsWithValue;
21use crate::api::groups::GroupEssentials;
22use crate::api::project_memberships::UserProjectMembership;
23use crate::api::{
24    CustomFieldFilter, DateTimeFilterPast, Endpoint, NoPagination, Pageable, QueryParams,
25    ReturnsJsonResponse,
26};
27use serde::Serialize;
28
29/// a minimal type for Redmine users used in
30/// other Redmine objects (e.g. issue author)
31#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
32pub struct UserEssentials {
33    /// numeric id
34    pub id: u64,
35    /// display name
36    pub name: String,
37}
38
39/// a type for user to use as an API return type
40///
41/// alternatively you can use your own type limited to the fields you need
42#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
43pub struct User {
44    /// numeric id
45    pub id: u64,
46    /// user status
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub status: Option<UserStatus>,
49    /// login name
50    pub login: String,
51    /// is this user an admin
52    pub admin: bool,
53    /// user's firstname
54    pub firstname: String,
55    /// user's lastname
56    pub lastname: String,
57    /// primary email of the user
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub mail: Option<String>,
60    /// the user's API key
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub api_key: Option<String>,
63    /// user's 2FA scheme
64    #[serde(default)]
65    pub twofa_scheme: Option<String>,
66    /// allows setting users to be e.g. LDAP users
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    auth_source_id: Option<u64>,
69    /// The time when this user was created
70    #[serde(
71        serialize_with = "crate::api::serialize_rfc3339",
72        deserialize_with = "crate::api::deserialize_rfc3339"
73    )]
74    pub created_on: time::OffsetDateTime,
75    /// The time when this user was last updated
76    #[serde(
77        serialize_with = "crate::api::serialize_rfc3339",
78        deserialize_with = "crate::api::deserialize_rfc3339"
79    )]
80    pub updated_on: time::OffsetDateTime,
81    /// The time when this user's password was last changed
82    #[serde(
83        serialize_with = "crate::api::serialize_optional_rfc3339",
84        deserialize_with = "crate::api::deserialize_optional_rfc3339"
85    )]
86    pub passwd_changed_on: Option<time::OffsetDateTime>,
87    /// the time when this user last logged in
88    #[serde(
89        serialize_with = "crate::api::serialize_optional_rfc3339",
90        deserialize_with = "crate::api::deserialize_optional_rfc3339"
91    )]
92    pub last_login_on: Option<time::OffsetDateTime>,
93    /// custom fields with values
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub custom_fields: Option<Vec<CustomFieldEssentialsWithValue>>,
96    /// groups (only if include is specified)
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub groups: Option<Vec<GroupEssentials>>,
99    /// memberships (only if include is specified)
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub memberships: Option<Vec<UserProjectMembership>>,
102}
103
104/// The user status values
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub enum UserStatus {
107    /// User can login and use their account (default)
108    Active,
109    /// User has registered but not yet confirmed their email address or was not yet activated by an administrator. User can not login
110    Registered,
111    /// User was once active and is now locked, User can not login
112    Locked,
113}
114
115impl serde::Serialize for UserStatus {
116    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
117    where
118        S: serde::Serializer,
119    {
120        match self {
121            Self::Active => serializer.serialize_u64(1),
122            Self::Registered => serializer.serialize_u64(2),
123            Self::Locked => serializer.serialize_u64(3),
124        }
125    }
126}
127
128impl<'de> serde::Deserialize<'de> for UserStatus {
129    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
130    where
131        D: serde::Deserializer<'de>,
132    {
133        let status_code = u64::deserialize(deserializer)?;
134        match status_code {
135            1 => Ok(Self::Active),
136            2 => Ok(Self::Registered),
137            3 => Ok(Self::Locked),
138            _ => Err(serde::de::Error::custom(format!(
139                "unknown user status code: {status_code}"
140            ))),
141        }
142    }
143}
144
145/// The user status values for filtering
146#[derive(Debug, Clone)]
147pub enum UserStatusFilter {
148    /// User can login and use their account (default)
149    Active,
150    /// User has registered but not yet confirmed their email address or was not yet activated by an administrator. User can not login
151    Registered,
152    /// User was once active and is now locked, User can not login
153    Locked,
154    /// Specify this to get users with any status
155    Any,
156}
157
158impl std::fmt::Display for UserStatusFilter {
159    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160        match self {
161            Self::Active => {
162                write!(f, "1")
163            }
164            Self::Registered => {
165                write!(f, "2")
166            }
167            Self::Locked => {
168                write!(f, "3")
169            }
170            Self::Any => {
171                write!(f, "*")
172            }
173        }
174    }
175}
176
177impl From<UserStatus> for UserStatusFilter {
178    fn from(value: UserStatus) -> Self {
179        match value {
180            UserStatus::Active => Self::Active,
181            UserStatus::Registered => Self::Registered,
182            UserStatus::Locked => Self::Locked,
183        }
184    }
185}
186
187/// The endpoint for all users
188#[derive(Debug, Clone, Builder)]
189#[builder(setter(strip_option))]
190pub struct ListUsers<'a> {
191    /// Filter by user status
192    #[builder(default)]
193    status: Option<UserStatusFilter>,
194    #[builder(default)]
195    /// Filter by name, this matches login, firstname, lastname, mail and if it contains a space also firstname and lastname
196    #[builder(setter(into))]
197    name: Option<Cow<'a, str>>,
198    /// Users need to be members of this group
199    #[builder(default)]
200    group_id: Option<u64>,
201    /// Filter by authentication source
202    #[builder(default)]
203    auth_source_id: Option<u64>,
204    /// Filter by the two-factor authentication scheme
205    #[builder(default)]
206    #[builder(setter(into))]
207    twofa_scheme: Option<Cow<'a, str>>,
208    /// A boolean filter to find only administrators
209    #[builder(default)]
210    admin: Option<bool>,
211    /// Filter by creation time
212    #[builder(default)]
213    created_on: Option<DateTimeFilterPast>,
214    /// Filter by last login time
215    #[builder(default)]
216    last_login_on: Option<DateTimeFilterPast>,
217    /// Filter by login
218    #[builder(default)]
219    #[builder(setter(into))]
220    login: Option<Cow<'a, str>>,
221    /// Filter by firstname
222    #[builder(default)]
223    #[builder(setter(into))]
224    firstname: Option<Cow<'a, str>>,
225    /// Filter by lastname
226    #[builder(default)]
227    #[builder(setter(into))]
228    lastname: Option<Cow<'a, str>>,
229    /// Filter by mail
230    #[builder(default)]
231    #[builder(setter(into))]
232    mail: Option<Cow<'a, str>>,
233    /// Filter by custom fields
234    #[builder(default)]
235    custom_field_filters: Option<Vec<CustomFieldFilter>>,
236}
237
238impl ReturnsJsonResponse for ListUsers<'_> {}
239impl Pageable for ListUsers<'_> {
240    fn response_wrapper_key(&self) -> String {
241        "users".to_string()
242    }
243}
244
245impl<'a> ListUsers<'a> {
246    /// Create a builder for the endpoint.
247    #[must_use]
248    pub fn builder() -> ListUsersBuilder<'a> {
249        ListUsersBuilder::default()
250    }
251}
252
253impl Endpoint for ListUsers<'_> {
254    fn method(&self) -> Method {
255        Method::GET
256    }
257
258    fn endpoint(&self) -> Cow<'static, str> {
259        "users.json".into()
260    }
261
262    fn parameters(&self) -> QueryParams<'_> {
263        let mut params = QueryParams::default();
264        params.push_opt("status", self.status.as_ref().map(|s| s.to_string()));
265        params.push_opt("name", self.name.as_ref());
266        params.push_opt("group_id", self.group_id);
267        params.push_opt("auth_source_id", self.auth_source_id);
268        params.push_opt("twofa_scheme", self.twofa_scheme.as_ref());
269        params.push_opt("admin", self.admin);
270        params.push_opt(
271            "created_on",
272            self.created_on.as_ref().map(|s| s.to_string()),
273        );
274        params.push_opt(
275            "last_login_on",
276            self.last_login_on.as_ref().map(|s| s.to_string()),
277        );
278        params.push_opt("login", self.login.as_ref());
279        params.push_opt("firstname", self.firstname.as_ref());
280        params.push_opt("lastname", self.lastname.as_ref());
281        params.push_opt("mail", self.mail.as_ref());
282        if let Some(filters) = self.custom_field_filters.as_ref() {
283            for filter in filters {
284                params.push(format!("cf_{}", filter.id), filter.value.to_string());
285            }
286        }
287        params
288    }
289}
290
291/// The types of associated data which can be fetched along with a user
292#[derive(Debug, Clone)]
293pub enum UserInclude {
294    /// The project memberships of this user
295    Memberships,
296    /// The groups where this user is a member
297    Groups,
298}
299
300impl std::fmt::Display for UserInclude {
301    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
302        match self {
303            Self::Memberships => {
304                write!(f, "memberships")
305            }
306            Self::Groups => {
307                write!(f, "groups")
308            }
309        }
310    }
311}
312
313/// The endpoint for a specific user
314#[derive(Debug, Clone, Builder)]
315#[builder(setter(strip_option))]
316pub struct GetUser {
317    /// User id to fetch, if not specified will fetch the current user
318    #[builder(default)]
319    id: Option<u64>,
320    /// Include associated data
321    #[builder(default)]
322    include: Option<Vec<UserInclude>>,
323}
324
325impl ReturnsJsonResponse for GetUser {}
326impl NoPagination for GetUser {}
327
328impl GetUser {
329    /// Create a builder for the endpoint.
330    #[must_use]
331    pub fn builder() -> GetUserBuilder {
332        GetUserBuilder::default()
333    }
334}
335
336impl Endpoint for GetUser {
337    fn method(&self) -> Method {
338        Method::GET
339    }
340
341    fn endpoint(&self) -> Cow<'static, str> {
342        match self.id {
343            Some(id) => format!("users/{id}.json").into(),
344            None => "users/current.json".into(),
345        }
346    }
347
348    fn parameters(&self) -> QueryParams<'_> {
349        let mut params = QueryParams::default();
350        params.push_opt("include", self.include.as_ref());
351        params
352    }
353}
354
355/// Possible values for mail notification options for a user
356#[derive(Debug, Clone, Serialize)]
357#[serde(rename_all = "snake_case")]
358pub enum MailNotificationOptions {
359    /// Get notified by all events (visible to user)
360    All,
361    /// This allows to be notified only by selected projects, not sure if those can be selected via the API
362    Selected,
363    /// Only get notifications for events caused by the user's own actions
364    OnlyMyEvents,
365    /// Only get notifications for events in issues assigned to the user
366    OnlyAssigned,
367    /// Only get notifications for events in issues owned by the user
368    OnlyOwner,
369    /// Do not get any notifications
370    #[serde(rename = "none")]
371    NoMailNotifications,
372}
373
374/// The endpoint to create a Redmine user
375#[serde_with::skip_serializing_none]
376#[derive(Debug, Clone, Builder, Serialize)]
377#[builder(setter(strip_option))]
378pub struct CreateUser<'a> {
379    /// The login for the user
380    #[builder(setter(into))]
381    login: Cow<'a, str>,
382    /// The user's password
383    ///
384    /// It is recommended to use generate_password instead
385    #[builder(setter(into), default)]
386    password: Option<Cow<'a, str>>,
387    /// The user's firstname
388    #[builder(setter(into))]
389    firstname: Cow<'a, str>,
390    /// The user's lastname
391    #[builder(setter(into))]
392    lastname: Cow<'a, str>,
393    /// The users primary email address
394    #[builder(setter(into))]
395    mail: Cow<'a, str>,
396    /// allows setting users to be e.g. LDAP users
397    #[builder(default)]
398    auth_source_id: Option<u64>,
399    /// what kind of mail notifications should be sent to the user
400    #[builder(default)]
401    mail_notification: Option<MailNotificationOptions>,
402    /// if set the user must change their password after the next login
403    #[builder(default)]
404    must_change_passwd: Option<bool>,
405    /// generate a random password
406    #[builder(default)]
407    generate_password: Option<bool>,
408    /// Send account information to the user
409    #[builder(default)]
410    #[serde(skip_serializing)]
411    send_information: Option<bool>,
412    /// Make the user a Redmine administrator
413    #[builder(default)]
414    admin: Option<bool>,
415}
416
417impl ReturnsJsonResponse for CreateUser<'_> {}
418impl NoPagination for CreateUser<'_> {}
419
420impl<'a> CreateUser<'a> {
421    /// Create a builder for the endpoint.
422    #[must_use]
423    pub fn builder() -> CreateUserBuilder<'a> {
424        CreateUserBuilder::default()
425    }
426}
427
428impl Endpoint for CreateUser<'_> {
429    fn method(&self) -> Method {
430        Method::POST
431    }
432
433    fn endpoint(&self) -> Cow<'static, str> {
434        "users.json".into()
435    }
436
437    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
438        Ok(Some((
439            "application/json",
440            serde_json::to_vec(&UserWrapperWithSendInformation::<CreateUser> {
441                user: (*self).to_owned(),
442                send_information: self.send_information,
443            })?,
444        )))
445    }
446}
447
448/// The endpoint to update an existing Redmine user
449#[serde_with::skip_serializing_none]
450#[derive(Debug, Clone, Builder, Serialize)]
451#[builder(setter(strip_option))]
452pub struct UpdateUser<'a> {
453    /// The id of the user to update
454    #[serde(skip_serializing)]
455    id: u64,
456    /// The login for the user
457    #[builder(setter(into))]
458    login: Cow<'a, str>,
459    /// The user's password
460    ///
461    /// It is recommended to use generate_password instead
462    #[builder(setter(into), default)]
463    password: Option<Cow<'a, str>>,
464    /// The user's firstname
465    #[builder(default, setter(into))]
466    firstname: Option<Cow<'a, str>>,
467    /// The user's lastname
468    #[builder(default, setter(into))]
469    lastname: Option<Cow<'a, str>>,
470    /// The users primary email address
471    #[builder(default, setter(into))]
472    mail: Option<Cow<'a, str>>,
473    /// allows setting users to be e.g. LDAP users
474    #[builder(default)]
475    auth_source_id: Option<u64>,
476    /// what kind of mail notifications should be sent to the user
477    #[builder(default)]
478    mail_notification: Option<MailNotificationOptions>,
479    /// if set the user must change their password after the next login
480    #[builder(default)]
481    must_change_passwd: Option<bool>,
482    /// generate a random password
483    #[builder(default)]
484    generate_password: Option<bool>,
485    /// Send account information to the user
486    #[builder(default)]
487    #[serde(skip_serializing)]
488    send_information: Option<bool>,
489    /// Make the user a Redmine administrator
490    #[builder(default)]
491    admin: Option<bool>,
492}
493
494impl<'a> UpdateUser<'a> {
495    /// Create a builder for the endpoint.
496    #[must_use]
497    pub fn builder() -> UpdateUserBuilder<'a> {
498        UpdateUserBuilder::default()
499    }
500}
501
502impl Endpoint for UpdateUser<'_> {
503    fn method(&self) -> Method {
504        Method::PUT
505    }
506
507    fn endpoint(&self) -> Cow<'static, str> {
508        format!("users/{}.json", self.id).into()
509    }
510
511    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
512        Ok(Some((
513            "application/json",
514            serde_json::to_vec(&UserWrapperWithSendInformation::<UpdateUser> {
515                user: (*self).to_owned(),
516                send_information: self.send_information,
517            })?,
518        )))
519    }
520}
521
522/// The endpoint to delete a Redmine user
523#[derive(Debug, Clone, Builder)]
524#[builder(setter(strip_option))]
525pub struct DeleteUser {
526    /// The id of the user to delete
527    id: u64,
528}
529
530impl DeleteUser {
531    /// Create a builder for the endpoint.
532    #[must_use]
533    pub fn builder() -> DeleteUserBuilder {
534        DeleteUserBuilder::default()
535    }
536}
537
538impl Endpoint for DeleteUser {
539    fn method(&self) -> Method {
540        Method::DELETE
541    }
542
543    fn endpoint(&self) -> Cow<'static, str> {
544        format!("users/{}.json", &self.id).into()
545    }
546}
547
548/// helper struct for outer layers with a users field holding the inner data
549#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
550pub struct UsersWrapper<T> {
551    /// to parse JSON with users key
552    pub users: Vec<T>,
553}
554
555/// A lot of APIs in Redmine wrap their data in an extra layer, this is a
556/// helper struct for outer layers with a user field holding the inner data
557#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
558pub struct UserWrapper<T> {
559    /// to parse JSON with user key
560    pub user: T,
561}
562
563/// a special version of the UserWrapper to use with [CreateUser] and [UpdateUser]
564/// because Redmine puts the send_information flag outside the user object for
565/// some reason
566#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
567pub struct UserWrapperWithSendInformation<T> {
568    /// to parse JSON with user key
569    pub user: T,
570    /// send information flag in [CreateUser] and [UpdateUser]
571    #[serde(default, skip_serializing_if = "Option::is_none")]
572    pub send_information: Option<bool>,
573}
574
575#[cfg(test)]
576pub(crate) mod test {
577    use crate::api::{
578        ResponsePage, groups::test::GROUP_LOCK, project_memberships::test::PROJECT_MEMBERSHIP_LOCK,
579    };
580
581    use super::*;
582    use pretty_assertions::assert_eq;
583    use std::error::Error;
584    use tokio::sync::RwLock;
585    use tracing_test::traced_test;
586
587    /// needed so we do not get 404s when listing while
588    /// creating/deleting or creating/updating/deleting
589    pub static USER_LOCK: RwLock<()> = RwLock::const_new(());
590
591    #[traced_test]
592    #[test]
593    fn test_list_users_first_page() -> Result<(), Box<dyn Error>> {
594        let _r_user = USER_LOCK.blocking_read();
595        dotenvy::dotenv()?;
596        let redmine = crate::api::Redmine::from_env(
597            reqwest::blocking::Client::builder()
598                .use_rustls_tls()
599                .build()?,
600        )?;
601        let endpoint = ListUsers::builder().build()?;
602        redmine.json_response_body_page::<_, User>(&endpoint, 0, 25)?;
603        Ok(())
604    }
605
606    #[traced_test]
607    #[test]
608    fn test_list_users_all_pages() -> Result<(), Box<dyn Error>> {
609        let _r_user = USER_LOCK.blocking_read();
610        dotenvy::dotenv()?;
611        let redmine = crate::api::Redmine::from_env(
612            reqwest::blocking::Client::builder()
613                .use_rustls_tls()
614                .build()?,
615        )?;
616        let endpoint = ListUsers::builder().build()?;
617        redmine.json_response_body_all_pages::<_, User>(&endpoint)?;
618        Ok(())
619    }
620
621    #[traced_test]
622    #[test]
623    fn test_get_user() -> Result<(), Box<dyn Error>> {
624        let _r_user = USER_LOCK.blocking_read();
625        dotenvy::dotenv()?;
626        let redmine = crate::api::Redmine::from_env(
627            reqwest::blocking::Client::builder()
628                .use_rustls_tls()
629                .build()?,
630        )?;
631        let endpoint = GetUser::builder().id(1).build()?;
632        redmine.json_response_body::<_, UserWrapper<User>>(&endpoint)?;
633        Ok(())
634    }
635
636    #[function_name::named]
637    #[traced_test]
638    #[test]
639    fn test_create_user() -> Result<(), Box<dyn Error>> {
640        let _w_user = USER_LOCK.blocking_write();
641        let name = format!("unittest_{}", function_name!());
642        dotenvy::dotenv()?;
643        let redmine = crate::api::Redmine::from_env(
644            reqwest::blocking::Client::builder()
645                .use_rustls_tls()
646                .build()?,
647        )?;
648        let list_endpoint = ListUsers::builder().name(name.clone()).build()?;
649        let users: Vec<User> = redmine.json_response_body_all_pages(&list_endpoint)?;
650        for user in users {
651            let delete_endpoint = DeleteUser::builder().id(user.id).build()?;
652            redmine.ignore_response_body::<_>(&delete_endpoint)?;
653        }
654        let create_endpoint = CreateUser::builder()
655            .login(name.clone())
656            .firstname("Unit")
657            .lastname("Test")
658            .mail(format!("unit-test_{name}@example.org"))
659            .build()?;
660        let UserWrapper { user } =
661            redmine.json_response_body::<_, UserWrapper<User>>(&create_endpoint)?;
662        let delete_endpoint = DeleteUser::builder().id(user.id).build()?;
663        redmine.ignore_response_body::<_>(&delete_endpoint)?;
664        Ok(())
665    }
666
667    // this test causes emails to be sent so we comment it out, mainly it was
668    // meant to check if the send_information attribute is inside or outside the
669    // user object in CreateUser (the docs in the wiki say outside and that really
670    // seems to be the case)
671    // #[function_name::named]
672    // #[traced_test]
673    // #[test]
674    // fn test_create_user_send_account_info() -> Result<(), Box<dyn Error>> {
675    //     let _w_user = USER_LOCK.blocking_write();
676    //     let name = format!("unittest_{}", function_name!());
677    //     dotenvy::dotenv()?;
678    //     let redmine = crate::api::Redmine::from_env()?;
679    //     let list_endpoint = ListUsers::builder().name(name.clone()).build()?;
680    //     let UsersWrapper { users } =
681    //         redmine.json_response_body::<_, UsersWrapper<User>>(&list_endpoint)?;
682    //     for user in users {
683    //         let delete_endpoint = DeleteUser::builder().id(user.id).build()?;
684    //         redmine.ignore_response_body::<_>(&delete_endpoint)?;
685    //     }
686    //     let create_endpoint = CreateUser::builder()
687    //         .login(name.clone())
688    //         .firstname("Unit")
689    //         .lastname("Test Send Account Info")
690    //         .mail(format!("{}@example.org", name)) // apparently there is a 60 character limit on the email in Redmine
691    //         .send_information(true)
692    //         .build()?;
693    //     let UserWrapper { user } =
694    //         redmine.json_response_body::<_, UserWrapper<User>>(&create_endpoint)?;
695    //     let delete_endpoint = DeleteUser::builder().id(user.id).build()?;
696    //     redmine.ignore_response_body::<_>(&delete_endpoint)?;
697    //     Ok(())
698    // }
699
700    // this test causes emails to be sent so we comment it out, mainly it was
701    // meant to check if the admin attribute is inside or outside the user object
702    // in CreateUser (the docs on the wiki say outside but inside seems
703    // to be correct)
704    // #[function_name::named]
705    // #[traced_test]
706    // #[test]
707    // fn test_create_admin_user() -> Result<(), Box<dyn Error>> {
708    //     let _w_user = USER_LOCK.blocking_write();
709    //     let name = format!("unittest_{}", function_name!());
710    //     dotenvy::dotenv()?;
711    //     let redmine = crate::api::Redmine::from_env()?;
712    //     let list_endpoint = ListUsers::builder().name(name.clone()).build()?;
713    //     let UsersWrapper { users } =
714    //         redmine.json_response_body::<_, UsersWrapper<User>>(&list_endpoint)?;
715    //     for user in users {
716    //         let delete_endpoint = DeleteUser::builder().id(user.id).build()?;
717    //         redmine.ignore_response_body::<_>(&delete_endpoint)?;
718    //     }
719    //     let create_endpoint = CreateUser::builder()
720    //         .login(name.clone())
721    //         .firstname("Unit")
722    //         .lastname("Test Admin")
723    //         .mail(format!("unit-test_{}@example.org", name))
724    //         .admin(true)
725    //         .build()?;
726    //     let UserWrapper { user } =
727    //         redmine.json_response_body::<_, UserWrapper<User>>(&create_endpoint)?;
728    //     let delete_endpoint = DeleteUser::builder().id(user.id).build()?;
729    //     redmine.ignore_response_body::<_>(&delete_endpoint)?;
730    //     Ok(())
731    // }
732
733    #[function_name::named]
734    #[traced_test]
735    #[test]
736    fn test_update_user() -> Result<(), Box<dyn Error>> {
737        let _w_user = USER_LOCK.blocking_write();
738        let name = format!("unittest_{}", function_name!());
739        dotenvy::dotenv()?;
740        let redmine = crate::api::Redmine::from_env(
741            reqwest::blocking::Client::builder()
742                .use_rustls_tls()
743                .build()?,
744        )?;
745        let list_endpoint = ListUsers::builder().name(name.clone()).build()?;
746        let users: Vec<User> = redmine.json_response_body_all_pages(&list_endpoint)?;
747        for user in users {
748            let delete_endpoint = DeleteUser::builder().id(user.id).build()?;
749            redmine.ignore_response_body::<_>(&delete_endpoint)?;
750        }
751        let create_endpoint = CreateUser::builder()
752            .login(name.clone())
753            .firstname("Unit")
754            .lastname("Test")
755            .mail(format!("unit-test_{name}@example.org"))
756            .build()?;
757        let UserWrapper { user } =
758            redmine.json_response_body::<_, UserWrapper<User>>(&create_endpoint)?;
759        let update_endpoint = super::UpdateUser::builder()
760            .id(user.id)
761            .login(format!("new_{name}"))
762            .build()?;
763        redmine.ignore_response_body::<_>(&update_endpoint)?;
764        let delete_endpoint = DeleteUser::builder().id(user.id).build()?;
765        redmine.ignore_response_body::<_>(&delete_endpoint)?;
766        Ok(())
767    }
768
769    /// this tests if any of the results contain a field we are not deserializing
770    ///
771    /// this will only catch fields we missed if they are part of the response but
772    /// it is better than nothing
773    #[traced_test]
774    #[test]
775    fn test_completeness_user_type_first_page() -> Result<(), Box<dyn Error>> {
776        let _r_user = USER_LOCK.blocking_read();
777        dotenvy::dotenv()?;
778        let redmine = crate::api::Redmine::from_env(
779            reqwest::blocking::Client::builder()
780                .use_rustls_tls()
781                .build()?,
782        )?;
783        let endpoint = ListUsers::builder().build()?;
784        let ResponsePage {
785            values,
786            total_count: _,
787            offset: _,
788            limit: _,
789        } = redmine.json_response_body_page::<_, serde_json::Value>(&endpoint, 0, 100)?;
790        for value in values {
791            let o: User = serde_json::from_value(value.clone())?;
792            let reserialized = serde_json::to_value(o)?;
793            assert_eq!(value, reserialized);
794        }
795        Ok(())
796    }
797
798    /// this tests if any of the results contain a field we are not deserializing
799    ///
800    /// this will only catch fields we missed if they are part of the response but
801    /// it is better than nothing
802    ///
803    /// this version of the test will load all pages of users and the individual
804    /// users for each via GetUser
805    #[traced_test]
806    #[test]
807    fn test_completeness_user_type_all_pages_all_user_details() -> Result<(), Box<dyn Error>> {
808        let _r_user = USER_LOCK.blocking_read();
809        let _r_groups = GROUP_LOCK.blocking_read();
810        let _r_project_memberships = PROJECT_MEMBERSHIP_LOCK.blocking_read();
811        dotenvy::dotenv()?;
812        let redmine = crate::api::Redmine::from_env(
813            reqwest::blocking::Client::builder()
814                .use_rustls_tls()
815                .build()?,
816        )?;
817        let endpoint = ListUsers::builder().build()?;
818        let users = redmine.json_response_body_all_pages::<_, User>(&endpoint)?;
819        for user in users {
820            let get_endpoint = GetUser::builder()
821                .id(user.id)
822                .include(vec![UserInclude::Memberships, UserInclude::Groups])
823                .build()?;
824            let UserWrapper { user: value } =
825                redmine.json_response_body::<_, UserWrapper<serde_json::Value>>(&get_endpoint)?;
826            let o: User = serde_json::from_value(value.clone())?;
827            let reserialized = serde_json::to_value(o)?;
828            assert_eq!(value, reserialized);
829        }
830        Ok(())
831    }
832}