redmine-api 0.11.4

API for the Redmine issue tracker
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
//! Wiki Pages Rest API Endpoint definitions
//!
//! [Redmine Documentation](https://www.redmine.org/projects/redmine/wiki/Rest_WikiPages)
//!
//! - [X] project specific wiki page endpoint
//! - [X] specific wiki page endpoint
//! - [X] specific wiki page old version endpoint
//! - [X] create or update wiki page endpoint
//! - [X] delete wiki page endpoint
//! - [ ] attachments
//!
//! The following endpoints always return 403 and are apparently not exposed in a usable way:
//! - GetProjectWikiPageHistory
//! - GetProjectWikiPageDiff
//! - RenameProjectWikiPage
//! - ProtectProjectWikiPage
//! - AddAttachmentToProjectWikiPage

use derive_builder::Builder;
use reqwest::Method;
use serde::Serialize;
use std::borrow::Cow;

use crate::api::attachments::Attachment;
use crate::api::users::UserEssentials;
use crate::api::{Endpoint, NoPagination, QueryParams, ReturnsJsonResponse};

/// The types of associated data which can be fetched along with a wiki page
#[derive(Debug, Clone)]
pub enum WikiPageInclude {
    /// Wiki Page Attachments
    Attachments,
}

impl std::fmt::Display for WikiPageInclude {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Attachments => {
                write!(f, "attachments")
            }
        }
    }
}

/// The parent of a wiki page
#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
pub struct WikiPageParent {
    /// title
    pub title: String,
}

/// a type for wiki pages to use as an API return type for the list call
///
/// alternatively you can use your own type limited to the fields you need
#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
pub struct WikiPageEssentials {
    /// title
    pub title: String,
    /// the parent of this page
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub parent: Option<WikiPageParent>,
    /// the version number of the wiki page
    pub version: u64,
    /// The time when this wiki page was created
    #[serde(
        serialize_with = "crate::api::serialize_rfc3339",
        deserialize_with = "crate::api::deserialize_rfc3339"
    )]
    pub created_on: time::OffsetDateTime,
    /// The time when this wiki page was last updated
    #[serde(
        serialize_with = "crate::api::serialize_rfc3339",
        deserialize_with = "crate::api::deserialize_rfc3339"
    )]
    pub updated_on: time::OffsetDateTime,
    /// wiki page attachments (only when include parameter is used)
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub attachments: Option<Vec<Attachment>>,
    /// is the wiki page protected
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub protected: Option<bool>,
}

/// a type for wiki pages to use as an API return type
///
/// alternatively you can use your own type limited to the fields you need
#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
pub struct WikiPage {
    /// title
    pub title: String,
    /// the parent of this page
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub parent: Option<WikiPageParent>,
    /// author
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub author: Option<UserEssentials>,
    /// the text body of the wiki page
    pub text: String,
    /// the version number of the wiki page
    pub version: u64,
    /// the comments supplied when saving this version of the page
    pub comments: String,
    /// The time when this wiki page was created
    #[serde(
        serialize_with = "crate::api::serialize_rfc3339",
        deserialize_with = "crate::api::deserialize_rfc3339"
    )]
    pub created_on: time::OffsetDateTime,
    /// The time when this wiki page was last updated
    #[serde(
        serialize_with = "crate::api::serialize_rfc3339",
        deserialize_with = "crate::api::deserialize_rfc3339"
    )]
    pub updated_on: time::OffsetDateTime,
    /// wiki page attachments (only when include parameter is used)
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub attachments: Option<Vec<Attachment>>,
    /// is the wiki page protected
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub protected: Option<bool>,
}

/// The endpoint for all wiki pages in a project
#[derive(Debug, Clone, Builder)]
#[builder(setter(strip_option))]
pub struct ListProjectWikiPages<'a> {
    /// project id or name as it appears in the URL
    #[builder(setter(into))]
    project_id_or_name: Cow<'a, str>,
    /// Include associated data
    #[builder(default)]
    include: Option<Vec<WikiPageInclude>>,
}

impl<'a> ReturnsJsonResponse for ListProjectWikiPages<'a> {}
impl<'a> NoPagination for ListProjectWikiPages<'a> {}

impl<'a> ListProjectWikiPages<'a> {
    /// Create a builder for the endpoint.
    #[must_use]
    pub fn builder() -> ListProjectWikiPagesBuilder<'a> {
        ListProjectWikiPagesBuilder::default()
    }
}

impl<'a> Endpoint for ListProjectWikiPages<'a> {
    fn method(&self) -> Method {
        Method::GET
    }

    fn endpoint(&self) -> Cow<'static, str> {
        format!("projects/{}/wiki/index.json", self.project_id_or_name).into()
    }

    fn parameters(&self) -> QueryParams<'_> {
        let mut params = QueryParams::default();
        params.push_opt("include", self.include.as_ref());
        params
    }
}

/// The endpoint for a specific Redmine project wiki page
#[derive(Debug, Clone, Builder)]
#[builder(setter(strip_option))]
pub struct GetProjectWikiPage<'a> {
    /// the project id or name as it appears in the URL
    #[builder(setter(into))]
    project_id_or_name: Cow<'a, str>,
    /// the title as it appears in the URL
    #[builder(setter(into))]
    title: Cow<'a, str>,
    /// the types of associate data to include
    #[builder(default)]
    include: Option<Vec<WikiPageInclude>>,
}

impl ReturnsJsonResponse for GetProjectWikiPage<'_> {}
impl NoPagination for GetProjectWikiPage<'_> {}

impl<'a> GetProjectWikiPage<'a> {
    /// Create a builder for the endpoint.
    #[must_use]
    pub fn builder() -> GetProjectWikiPageBuilder<'a> {
        GetProjectWikiPageBuilder::default()
    }
}

impl Endpoint for GetProjectWikiPage<'_> {
    fn method(&self) -> Method {
        Method::GET
    }

    fn endpoint(&self) -> Cow<'static, str> {
        format!(
            "projects/{}/wiki/{}.json",
            &self.project_id_or_name, &self.title
        )
        .into()
    }

    fn parameters(&self) -> QueryParams<'_> {
        let mut params = QueryParams::default();
        params.push_opt("include", self.include.as_ref());
        params
    }
}

/// The endpoint for a specific Redmine project wiki page version
#[derive(Debug, Clone, Builder)]
#[builder(setter(strip_option))]
pub struct GetProjectWikiPageVersion<'a> {
    /// the project id or name as it appears in the URL
    #[builder(setter(into))]
    project_id_or_name: Cow<'a, str>,
    /// the title as it appears in the URL
    #[builder(setter(into))]
    title: Cow<'a, str>,
    /// the version
    version: u64,
    /// the types of associate data to include
    #[builder(default)]
    include: Option<Vec<WikiPageInclude>>,
}

impl ReturnsJsonResponse for GetProjectWikiPageVersion<'_> {}
impl NoPagination for GetProjectWikiPageVersion<'_> {}

impl<'a> GetProjectWikiPageVersion<'a> {
    /// Create a builder for the endpoint.
    #[must_use]
    pub fn builder() -> GetProjectWikiPageVersionBuilder<'a> {
        GetProjectWikiPageVersionBuilder::default()
    }
}

impl Endpoint for GetProjectWikiPageVersion<'_> {
    fn method(&self) -> Method {
        Method::GET
    }

    fn endpoint(&self) -> Cow<'static, str> {
        format!(
            "projects/{}/wiki/{}/{}.json",
            &self.project_id_or_name, &self.title, &self.version,
        )
        .into()
    }

    fn parameters(&self) -> QueryParams<'_> {
        let mut params = QueryParams::default();
        params.push_opt("include", self.include.as_ref());
        params
    }
}

/// The endpoint to create or update a Redmine project wiki page
#[derive(Debug, Clone, Builder, serde::Serialize, serde::Deserialize)]
#[builder(setter(strip_option))]
pub struct CreateOrUpdateProjectWikiPage<'a> {
    /// the project id or name as it appears in the URL
    #[serde(skip_serializing)]
    #[builder(setter(into))]
    project_id_or_name: Cow<'a, str>,
    /// the title as it appears in the URL
    #[serde(skip_serializing)]
    #[builder(setter(into))]
    title: Cow<'a, str>,
    /// the version to update, if the version is not this a 409 Conflict is returned
    #[serde(skip_serializing_if = "Option::is_none")]
    #[builder(default)]
    version: Option<u64>,
    /// the body text of the page
    #[builder(setter(into))]
    text: Cow<'a, str>,
    /// the comment for the update history
    #[builder(setter(into))]
    comments: Cow<'a, str>,
    /// used when renaming or moving a page
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[builder(default)]
    redirect_existing_links: Option<bool>,
    /// is the wiki page the start page for the project
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[builder(default)]
    is_start_page: Option<bool>,
}

impl<'a> CreateOrUpdateProjectWikiPage<'a> {
    /// Create a builder for the endpoint.
    #[must_use]
    pub fn builder() -> CreateOrUpdateProjectWikiPageBuilder<'a> {
        CreateOrUpdateProjectWikiPageBuilder::default()
    }
}

impl Endpoint for CreateOrUpdateProjectWikiPage<'_> {
    fn method(&self) -> Method {
        Method::PUT
    }

    fn endpoint(&self) -> Cow<'static, str> {
        format!(
            "projects/{}/wiki/{}.json",
            &self.project_id_or_name, &self.title
        )
        .into()
    }

    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
        Ok(Some((
            "application/json",
            serde_json::to_vec(&WikiPageWrapper::<CreateOrUpdateProjectWikiPage> {
                wiki_page: (*self).to_owned(),
            })?,
        )))
    }
}

/// The endpoint to delete a Redmine project wiki page
#[derive(Debug, Clone, Builder)]
#[builder(setter(strip_option))]
pub struct DeleteProjectWikiPage<'a> {
    /// the project id or name as it appears in the URL
    #[builder(setter(into))]
    project_id_or_name: Cow<'a, str>,
    /// the title as it appears in the URL
    #[builder(setter(into))]
    title: Cow<'a, str>,
    /// what to do with descendant pages: `null` (default) or `destroy`
    #[builder(default)]
    todo: Option<Cow<'a, str>>,
    /// the id of the wiki page to reassign descendant pages to
    #[builder(default)]
    reassign_to_id: Option<u64>,
}

impl<'a> DeleteProjectWikiPage<'a> {
    /// Create a builder for the endpoint.
    #[must_use]
    pub fn builder() -> DeleteProjectWikiPageBuilder<'a> {
        DeleteProjectWikiPageBuilder::default()
    }
}

impl<'a> Endpoint for DeleteProjectWikiPage<'a> {
    fn method(&self) -> Method {
        Method::DELETE
    }

    fn endpoint(&self) -> Cow<'static, str> {
        format!(
            "projects/{}/wiki/{}.json",
            &self.project_id_or_name, &self.title
        )
        .into()
    }

    fn parameters(&self) -> QueryParams<'_> {
        let mut params = QueryParams::default();
        params.push_opt("todo", self.todo.as_ref());
        params.push_opt("reassign_to_id", self.reassign_to_id);
        params
    }
}

/// The endpoint to delete a specific version of a Redmine project wiki page
#[derive(Debug, Clone, Builder)]
#[builder(setter(strip_option))]
pub struct DeleteProjectWikiPageVersion<'a> {
    /// the project id or name as it appears in the URL
    #[builder(setter(into))]
    project_id_or_name: Cow<'a, str>,
    /// the title as it appears in the URL
    #[builder(setter(into))]
    title: Cow<'a, str>,
    /// the version to delete
    version: u64,
}

impl<'a> DeleteProjectWikiPageVersion<'a> {
    /// Create a builder for the endpoint.
    #[must_use]
    pub fn builder() -> DeleteProjectWikiPageVersionBuilder<'a> {
        DeleteProjectWikiPageVersionBuilder::default()
    }
}

impl<'a> Endpoint for DeleteProjectWikiPageVersion<'a> {
    fn method(&self) -> Method {
        Method::DELETE
    }

    fn endpoint(&self) -> Cow<'static, str> {
        format!(
            "projects/{}/wiki/{}/{}/destroy_version.json",
            &self.project_id_or_name, &self.title, &self.version
        )
        .into()
    }
}

/// The endpoint to get the annotated view of a Redmine project wiki page
#[derive(Debug, Clone, Builder)]
#[builder(setter(strip_option))]
pub struct GetProjectWikiPageAnnotate<'a> {
    /// the project id or name as it appears in the URL
    #[builder(setter(into))]
    project_id_or_name: Cow<'a, str>,
    /// the title as it appears in the URL
    #[builder(setter(into))]
    title: Cow<'a, str>,
    /// the version to annotate
    version: u64,
}

impl<'a> GetProjectWikiPageAnnotate<'a> {
    /// Create a builder for the endpoint.
    #[must_use]
    pub fn builder() -> GetProjectWikiPageAnnotateBuilder<'a> {
        GetProjectWikiPageAnnotateBuilder::default()
    }
}

impl NoPagination for GetProjectWikiPageAnnotate<'_> {}

impl Endpoint for GetProjectWikiPageAnnotate<'_> {
    fn method(&self) -> Method {
        Method::GET
    }

    fn endpoint(&self) -> Cow<'static, str> {
        format!(
            "projects/{}/wiki/{}/annotate.json",
            &self.project_id_or_name, &self.title
        )
        .into()
    }

    fn parameters(&self) -> QueryParams<'_> {
        let mut params = QueryParams::default();
        params.push("v", self.version);
        params
    }
}

/// The endpoint to export a Redmine project wiki page
#[derive(Debug, Clone, Builder)]
#[builder(setter(strip_option))]
pub struct ExportProjectWikiPage<'a> {
    /// the project id or name as it appears in the URL
    #[builder(setter(into))]
    project_id_or_name: Cow<'a, str>,
    /// the title as it appears in the URL
    #[builder(setter(into))]
    title: Cow<'a, str>,
    /// the version to export
    #[builder(default)]
    version: Option<u64>,
}

impl<'a> ExportProjectWikiPage<'a> {
    /// Create a builder for the endpoint.
    #[must_use]
    pub fn builder() -> ExportProjectWikiPageBuilder<'a> {
        ExportProjectWikiPageBuilder::default()
    }
}

impl NoPagination for ExportProjectWikiPage<'_> {}

impl Endpoint for ExportProjectWikiPage<'_> {
    fn method(&self) -> Method {
        Method::GET
    }

    fn endpoint(&self) -> Cow<'static, str> {
        format!(
            "projects/{}/wiki/{}/export.json",
            &self.project_id_or_name, &self.title
        )
        .into()
    }

    fn parameters(&self) -> QueryParams<'_> {
        let mut params = QueryParams::default();
        params.push_opt("v", self.version);
        params
    }
}

/// helper struct for outer layers with a wiki_pages field holding the inner data
#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
pub struct WikiPagesWrapper<T> {
    /// to parse JSON with wiki_pages key
    pub wiki_pages: Vec<T>,
}

/// A lot of APIs in Redmine wrap their data in an extra layer, this is a
/// helper struct for outer layers with a wiki_page field holding the inner data
#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
pub struct WikiPageWrapper<T> {
    /// to parse JSON with an wiki_page key
    pub wiki_page: T,
}

#[cfg(test)]
pub(crate) mod test {
    use crate::api::projects::{ListProjects, Project, ProjectsInclude, test::PROJECT_LOCK};

    use super::*;
    use std::error::Error;
    use tokio::sync::RwLock;
    use tracing_test::traced_test;

    /// needed so we do not get 404s when listing while
    /// creating/deleting or creating/updating/deleting
    pub static PROJECT_WIKI_PAGE_LOCK: RwLock<()> = RwLock::const_new(());

    #[traced_test]
    #[test]
    fn test_list_project_wiki_pages() -> Result<(), Box<dyn Error>> {
        let _r_project = PROJECT_LOCK.blocking_read();
        let _r_project_wiki_pages = PROJECT_WIKI_PAGE_LOCK.blocking_read();
        dotenvy::dotenv()?;
        let redmine = crate::api::Redmine::from_env(
            reqwest::blocking::Client::builder()
                .tls_backend_rustls()
                .build()?,
        )?;
        let endpoint = ListProjectWikiPages::builder()
            .project_id_or_name("25")
            .build()?;
        redmine.json_response_body::<_, WikiPagesWrapper<WikiPageEssentials>>(&endpoint)?;
        Ok(())
    }

    /// this tests if any of the results contain a field we are not deserializing
    ///
    /// this will only catch fields we missed if they are part of the response but
    /// it is better than nothing
    #[traced_test]
    #[test]
    fn test_completeness_wiki_page_essentials() -> Result<(), Box<dyn Error>> {
        let _r_project = PROJECT_LOCK.blocking_read();
        let _r_issues = PROJECT_WIKI_PAGE_LOCK.blocking_read();
        dotenvy::dotenv()?;
        let redmine = crate::api::Redmine::from_env(
            reqwest::blocking::Client::builder()
                .tls_backend_rustls()
                .build()?,
        )?;
        let endpoint = ListProjects::builder()
            .include(vec![ProjectsInclude::EnabledModules])
            .build()?;
        let projects = redmine.json_response_body_all_pages_iter::<_, Project>(&endpoint);
        let mut checked_projects = 0;
        for project in projects {
            let project = project?;
            if !project
                .enabled_modules
                .is_some_and(|em| em.iter().any(|m| m.name == "wiki"))
            {
                // skip projects where wiki is disabled
                continue;
            }
            let endpoint = ListProjectWikiPages::builder()
                .project_id_or_name(project.id.to_string())
                .include(vec![WikiPageInclude::Attachments])
                .build()?;
            let Ok(WikiPagesWrapper { wiki_pages: values }) =
                redmine.json_response_body::<_, WikiPagesWrapper<serde_json::Value>>(&endpoint)
            else {
                // TODO: some projects return a 404 for their wiki for unknown reasons even with an
                //       enabled wiki module. They also do not have a wiki tab so I assume
                //       it is intentional, they are not closed or archived either
                //
                //       Further analysis seems to indicate that this should not happen and is most
                //       likely an issue resulting from a database state from a buggy old version of
                //       Redmine
                continue;
            };
            checked_projects += 1;
            for value in values {
                let o: WikiPageEssentials = serde_json::from_value(value.clone())?;
                let reserialized = serde_json::to_value(o)?;
                assert_eq!(value, reserialized);
            }
        }
        assert!(checked_projects > 0);
        Ok(())
    }

    #[traced_test]
    #[test]
    fn test_get_project_wiki_page() -> Result<(), Box<dyn Error>> {
        let _r_project = PROJECT_LOCK.blocking_read();
        let _r_project_wiki_pages = PROJECT_WIKI_PAGE_LOCK.blocking_read();
        dotenvy::dotenv()?;
        let redmine = crate::api::Redmine::from_env(
            reqwest::blocking::Client::builder()
                .tls_backend_rustls()
                .build()?,
        )?;
        let endpoint = GetProjectWikiPage::builder()
            .project_id_or_name("25")
            .title("Administration")
            .build()?;
        redmine.json_response_body::<_, WikiPageWrapper<WikiPage>>(&endpoint)?;
        Ok(())
    }

    /// this tests if any of the results contain a field we are not deserializing
    ///
    /// this will only catch fields we missed if they are part of the response but
    /// it is better than nothing
    #[traced_test]
    #[test]
    fn test_completeness_wiki_page() -> Result<(), Box<dyn Error>> {
        let _r_project = PROJECT_LOCK.blocking_read();
        let _r_issues = PROJECT_WIKI_PAGE_LOCK.blocking_read();
        dotenvy::dotenv()?;
        let redmine = crate::api::Redmine::from_env(
            reqwest::blocking::Client::builder()
                .tls_backend_rustls()
                .build()?,
        )?;
        let endpoint = ListProjects::builder()
            .include(vec![ProjectsInclude::EnabledModules])
            .build()?;
        let projects = redmine.json_response_body_all_pages_iter::<_, Project>(&endpoint);
        let mut checked_pages = 0;
        for project in projects {
            let project = project?;
            if !project
                .enabled_modules
                .is_some_and(|em| em.iter().any(|m| m.name == "wiki"))
            {
                // skip projects where wiki is disabled
                continue;
            }
            let endpoint = ListProjectWikiPages::builder()
                .project_id_or_name(project.id.to_string())
                .include(vec![WikiPageInclude::Attachments])
                .build()?;
            let Ok(WikiPagesWrapper { wiki_pages }) =
                redmine.json_response_body::<_, WikiPagesWrapper<WikiPageEssentials>>(&endpoint)
            else {
                // TODO: some projects return a 404 for their wiki for unknown reasons even with an
                //       enabled wiki module. They also do not have a wiki tab so I assume
                //       it is intentional, they are not closed or archived either
                //
                //       Further analysis seems to indicate that this should not happen and is most
                //       likely an issue resulting from a database state from a buggy old version of
                //       Redmine
                continue;
            };
            checked_pages += 1;
            for wiki_page in wiki_pages {
                let endpoint = GetProjectWikiPage::builder()
                    .project_id_or_name(project.id.to_string())
                    .title(wiki_page.title)
                    .include(vec![WikiPageInclude::Attachments])
                    .build()?;
                let WikiPageWrapper { wiki_page: value } = redmine
                    .json_response_body::<_, WikiPageWrapper<serde_json::Value>>(&endpoint)?;
                let o: WikiPage = serde_json::from_value(value.clone())?;
                let reserialized = serde_json::to_value(o)?;
                assert_eq!(value, reserialized);
            }
        }
        assert!(checked_pages > 0);
        Ok(())
    }

    #[traced_test]
    #[test]
    fn test_get_project_wiki_page_version() -> Result<(), Box<dyn Error>> {
        let _r_project = PROJECT_LOCK.blocking_read();
        let _r_project_wiki_pages = PROJECT_WIKI_PAGE_LOCK.blocking_read();
        dotenvy::dotenv()?;
        let redmine = crate::api::Redmine::from_env(
            reqwest::blocking::Client::builder()
                .tls_backend_rustls()
                .build()?,
        )?;
        let endpoint = GetProjectWikiPageVersion::builder()
            .project_id_or_name("25")
            .title("Administration")
            .version(18)
            .build()?;
        redmine.json_response_body::<_, WikiPageWrapper<WikiPage>>(&endpoint)?;
        Ok(())
    }

    #[traced_test]
    #[test]
    fn test_create_update_and_delete_project_wiki_page() -> Result<(), Box<dyn Error>> {
        let _r_project = PROJECT_LOCK.blocking_read();
        let _w_project_wiki_pages = PROJECT_WIKI_PAGE_LOCK.blocking_write();
        dotenvy::dotenv()?;
        let redmine = crate::api::Redmine::from_env(
            reqwest::blocking::Client::builder()
                .tls_backend_rustls()
                .build()?,
        )?;
        let endpoint = GetProjectWikiPage::builder()
            .project_id_or_name("25")
            .title("CreateWikiPageTest")
            .build()?;
        if redmine.ignore_response_body(&endpoint).is_ok() {
            // left-over from past test that failed to complete
            let endpoint = DeleteProjectWikiPage::builder()
                .project_id_or_name("25")
                .title("CreateWikiPageTest")
                .build()?;
            redmine.ignore_response_body(&endpoint)?;
        }
        let endpoint = CreateOrUpdateProjectWikiPage::builder()
            .project_id_or_name("25")
            .title("CreateWikiPageTest")
            .text("Test Content")
            .comments("Create Page Test")
            .build()?;
        redmine.ignore_response_body(&endpoint)?;
        let endpoint = CreateOrUpdateProjectWikiPage::builder()
            .project_id_or_name("25")
            .title("CreateWikiPageTest")
            .text("Test Content Updates")
            .version(1)
            .comments("Update Page Test")
            .build()?;
        redmine.ignore_response_body(&endpoint)?;
        let endpoint = DeleteProjectWikiPage::builder()
            .project_id_or_name("25")
            .title("CreateWikiPageTest")
            .build()?;
        redmine.ignore_response_body(&endpoint)?;
        Ok(())
    }

    #[traced_test]
    #[test]
    fn test_wiki_page_lifecycle() -> Result<(), Box<dyn Error>> {
        use crate::api::test_helpers::with_project;

        with_project("test_wiki_page_lifecycle", |redmine, project_id, _| {
            tracing::debug!("Creating wiki page TestWikiPage");
            let endpoint = CreateOrUpdateProjectWikiPage::builder()
                .project_id_or_name(project_id.to_string())
                .title("TestWikiPage")
                .text("Test Content")
                .comments("Create Page Test")
                .build()?;
            redmine.ignore_response_body(&endpoint)?;

            tracing::debug!("Verifying existence, content and version of wiki page TestWikiPage");
            let get_endpoint = GetProjectWikiPage::builder()
                .project_id_or_name(project_id.to_string())
                .title("TestWikiPage")
                .build()?;
            let WikiPageWrapper { wiki_page } =
                redmine.json_response_body::<_, WikiPageWrapper<WikiPage>>(&get_endpoint)?;
            assert_eq!(wiki_page.text, "Test Content");
            assert_eq!(wiki_page.version, 1);

            tracing::debug!("Updating wiki page TestWikiPage");
            let update_endpoint = CreateOrUpdateProjectWikiPage::builder()
                .project_id_or_name(project_id.to_string())
                .title("TestWikiPage")
                .text("Test Content Updates")
                .version(1)
                .comments("Update Page Test")
                .build()?;
            redmine.ignore_response_body(&update_endpoint)?;

            tracing::debug!(
                "Verifying existence, content and version of updated wiki page TestWikiPage"
            );
            let get_endpoint = GetProjectWikiPage::builder()
                .project_id_or_name(project_id.to_string())
                .title("TestWikiPage")
                .build()?;
            let WikiPageWrapper { wiki_page } =
                redmine.json_response_body::<_, WikiPageWrapper<WikiPage>>(&get_endpoint)?;
            assert_eq!(wiki_page.text, "Test Content Updates");
            assert_eq!(wiki_page.version, 2);

            tracing::debug!("Verifying existence and content of wiki page TestWikiPage version 1");
            let version_endpoint = GetProjectWikiPageVersion::builder()
                .project_id_or_name(project_id.to_string())
                .title("TestWikiPage")
                .version(1)
                .build()?;
            let WikiPageWrapper { wiki_page } =
                redmine.json_response_body::<_, WikiPageWrapper<WikiPage>>(&version_endpoint)?;
            assert_eq!(wiki_page.text, "Test Content");

            tracing::debug!("Deleting wiki page TestWikiPage");
            let delete_endpoint = DeleteProjectWikiPage::builder()
                .project_id_or_name(project_id.to_string())
                .title("TestWikiPage")
                .build()?;
            redmine.ignore_response_body(&delete_endpoint)?;

            Ok(())
        })
    }
}