redmine_api/api/
wiki_pages.rs

1//! Wiki Pages Rest API Endpoint definitions
2//!
3//! [Redmine Documentation](https://www.redmine.org/projects/redmine/wiki/Rest_WikiPages)
4//!
5//! - [X] project specific wiki page endpoint
6//! - [X] specific wiki page endpoint
7//! - [X] specific wiki page old version endpoint
8//! - [X] create or update wiki page endpoint
9//! - [X] delete wiki page endpoint
10//! - [ ] attachments
11//!
12//! The following endpoints always return 403 and are apparently not exposed in a usable way:
13//! - GetProjectWikiPageHistory
14//! - GetProjectWikiPageDiff
15//! - RenameProjectWikiPage
16//! - ProtectProjectWikiPage
17//! - AddAttachmentToProjectWikiPage
18
19use derive_builder::Builder;
20use reqwest::Method;
21use serde::Serialize;
22use std::borrow::Cow;
23
24use crate::api::attachments::Attachment;
25use crate::api::users::UserEssentials;
26use crate::api::{Endpoint, NoPagination, QueryParams, ReturnsJsonResponse};
27
28/// The types of associated data which can be fetched along with a wiki page
29#[derive(Debug, Clone)]
30pub enum WikiPageInclude {
31    /// Wiki Page Attachments
32    Attachments,
33}
34
35impl std::fmt::Display for WikiPageInclude {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        match self {
38            Self::Attachments => {
39                write!(f, "attachments")
40            }
41        }
42    }
43}
44
45/// The parent of a wiki page
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
47pub struct WikiPageParent {
48    /// title
49    pub title: String,
50}
51
52/// a type for wiki pages to use as an API return type for the list call
53///
54/// alternatively you can use your own type limited to the fields you need
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
56pub struct WikiPageEssentials {
57    /// title
58    pub title: String,
59    /// the parent of this page
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub parent: Option<WikiPageParent>,
62    /// the version number of the wiki page
63    pub version: u64,
64    /// The time when this wiki page was created
65    #[serde(
66        serialize_with = "crate::api::serialize_rfc3339",
67        deserialize_with = "crate::api::deserialize_rfc3339"
68    )]
69    pub created_on: time::OffsetDateTime,
70    /// The time when this wiki page was last updated
71    #[serde(
72        serialize_with = "crate::api::serialize_rfc3339",
73        deserialize_with = "crate::api::deserialize_rfc3339"
74    )]
75    pub updated_on: time::OffsetDateTime,
76    /// wiki page attachments (only when include parameter is used)
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub attachments: Option<Vec<Attachment>>,
79    /// is the wiki page protected
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub protected: Option<bool>,
82}
83
84/// a type for wiki pages to use as an API return type
85///
86/// alternatively you can use your own type limited to the fields you need
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
88pub struct WikiPage {
89    /// title
90    pub title: String,
91    /// the parent of this page
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub parent: Option<WikiPageParent>,
94    /// author
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub author: Option<UserEssentials>,
97    /// the text body of the wiki page
98    pub text: String,
99    /// the version number of the wiki page
100    pub version: u64,
101    /// the comments supplied when saving this version of the page
102    pub comments: String,
103    /// The time when this wiki page was created
104    #[serde(
105        serialize_with = "crate::api::serialize_rfc3339",
106        deserialize_with = "crate::api::deserialize_rfc3339"
107    )]
108    pub created_on: time::OffsetDateTime,
109    /// The time when this wiki page was last updated
110    #[serde(
111        serialize_with = "crate::api::serialize_rfc3339",
112        deserialize_with = "crate::api::deserialize_rfc3339"
113    )]
114    pub updated_on: time::OffsetDateTime,
115    /// wiki page attachments (only when include parameter is used)
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub attachments: Option<Vec<Attachment>>,
118    /// is the wiki page protected
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub protected: Option<bool>,
121}
122
123/// The endpoint for all wiki pages in a project
124#[derive(Debug, Clone, Builder)]
125#[builder(setter(strip_option))]
126pub struct ListProjectWikiPages<'a> {
127    /// project id or name as it appears in the URL
128    #[builder(setter(into))]
129    project_id_or_name: Cow<'a, str>,
130    /// Include associated data
131    #[builder(default)]
132    include: Option<Vec<WikiPageInclude>>,
133}
134
135impl<'a> ReturnsJsonResponse for ListProjectWikiPages<'a> {}
136impl<'a> NoPagination for ListProjectWikiPages<'a> {}
137
138impl<'a> ListProjectWikiPages<'a> {
139    /// Create a builder for the endpoint.
140    #[must_use]
141    pub fn builder() -> ListProjectWikiPagesBuilder<'a> {
142        ListProjectWikiPagesBuilder::default()
143    }
144}
145
146impl<'a> Endpoint for ListProjectWikiPages<'a> {
147    fn method(&self) -> Method {
148        Method::GET
149    }
150
151    fn endpoint(&self) -> Cow<'static, str> {
152        format!("projects/{}/wiki/index.json", self.project_id_or_name).into()
153    }
154
155    fn parameters(&self) -> QueryParams<'_> {
156        let mut params = QueryParams::default();
157        params.push_opt("include", self.include.as_ref());
158        params
159    }
160}
161
162/// The endpoint for a specific Redmine project wiki page
163#[derive(Debug, Clone, Builder)]
164#[builder(setter(strip_option))]
165pub struct GetProjectWikiPage<'a> {
166    /// the project id or name as it appears in the URL
167    #[builder(setter(into))]
168    project_id_or_name: Cow<'a, str>,
169    /// the title as it appears in the URL
170    #[builder(setter(into))]
171    title: Cow<'a, str>,
172    /// the types of associate data to include
173    #[builder(default)]
174    include: Option<Vec<WikiPageInclude>>,
175}
176
177impl ReturnsJsonResponse for GetProjectWikiPage<'_> {}
178impl NoPagination for GetProjectWikiPage<'_> {}
179
180impl<'a> GetProjectWikiPage<'a> {
181    /// Create a builder for the endpoint.
182    #[must_use]
183    pub fn builder() -> GetProjectWikiPageBuilder<'a> {
184        GetProjectWikiPageBuilder::default()
185    }
186}
187
188impl Endpoint for GetProjectWikiPage<'_> {
189    fn method(&self) -> Method {
190        Method::GET
191    }
192
193    fn endpoint(&self) -> Cow<'static, str> {
194        format!(
195            "projects/{}/wiki/{}.json",
196            &self.project_id_or_name, &self.title
197        )
198        .into()
199    }
200
201    fn parameters(&self) -> QueryParams<'_> {
202        let mut params = QueryParams::default();
203        params.push_opt("include", self.include.as_ref());
204        params
205    }
206}
207
208/// The endpoint for a specific Redmine project wiki page version
209#[derive(Debug, Clone, Builder)]
210#[builder(setter(strip_option))]
211pub struct GetProjectWikiPageVersion<'a> {
212    /// the project id or name as it appears in the URL
213    #[builder(setter(into))]
214    project_id_or_name: Cow<'a, str>,
215    /// the title as it appears in the URL
216    #[builder(setter(into))]
217    title: Cow<'a, str>,
218    /// the version
219    version: u64,
220    /// the types of associate data to include
221    #[builder(default)]
222    include: Option<Vec<WikiPageInclude>>,
223}
224
225impl ReturnsJsonResponse for GetProjectWikiPageVersion<'_> {}
226impl NoPagination for GetProjectWikiPageVersion<'_> {}
227
228impl<'a> GetProjectWikiPageVersion<'a> {
229    /// Create a builder for the endpoint.
230    #[must_use]
231    pub fn builder() -> GetProjectWikiPageVersionBuilder<'a> {
232        GetProjectWikiPageVersionBuilder::default()
233    }
234}
235
236impl Endpoint for GetProjectWikiPageVersion<'_> {
237    fn method(&self) -> Method {
238        Method::GET
239    }
240
241    fn endpoint(&self) -> Cow<'static, str> {
242        format!(
243            "projects/{}/wiki/{}/{}.json",
244            &self.project_id_or_name, &self.title, &self.version,
245        )
246        .into()
247    }
248
249    fn parameters(&self) -> QueryParams<'_> {
250        let mut params = QueryParams::default();
251        params.push_opt("include", self.include.as_ref());
252        params
253    }
254}
255
256/// The endpoint to create or update a Redmine project wiki page
257#[derive(Debug, Clone, Builder, serde::Serialize, serde::Deserialize)]
258#[builder(setter(strip_option))]
259pub struct CreateOrUpdateProjectWikiPage<'a> {
260    /// the project id or name as it appears in the URL
261    #[serde(skip_serializing)]
262    #[builder(setter(into))]
263    project_id_or_name: Cow<'a, str>,
264    /// the title as it appears in the URL
265    #[serde(skip_serializing)]
266    #[builder(setter(into))]
267    title: Cow<'a, str>,
268    /// the version to update, if the version is not this a 409 Conflict is returned
269    #[serde(skip_serializing_if = "Option::is_none")]
270    #[builder(default)]
271    version: Option<u64>,
272    /// the body text of the page
273    #[builder(setter(into))]
274    text: Cow<'a, str>,
275    /// the comment for the update history
276    #[builder(setter(into))]
277    comments: Cow<'a, str>,
278    /// used when renaming or moving a page
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    #[builder(default)]
281    redirect_existing_links: Option<bool>,
282    /// is the wiki page the start page for the project
283    #[serde(default, skip_serializing_if = "Option::is_none")]
284    #[builder(default)]
285    is_start_page: Option<bool>,
286}
287
288impl<'a> CreateOrUpdateProjectWikiPage<'a> {
289    /// Create a builder for the endpoint.
290    #[must_use]
291    pub fn builder() -> CreateOrUpdateProjectWikiPageBuilder<'a> {
292        CreateOrUpdateProjectWikiPageBuilder::default()
293    }
294}
295
296impl Endpoint for CreateOrUpdateProjectWikiPage<'_> {
297    fn method(&self) -> Method {
298        Method::PUT
299    }
300
301    fn endpoint(&self) -> Cow<'static, str> {
302        format!(
303            "projects/{}/wiki/{}.json",
304            &self.project_id_or_name, &self.title
305        )
306        .into()
307    }
308
309    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
310        Ok(Some((
311            "application/json",
312            serde_json::to_vec(&WikiPageWrapper::<CreateOrUpdateProjectWikiPage> {
313                wiki_page: (*self).to_owned(),
314            })?,
315        )))
316    }
317}
318
319/// The endpoint to delete a Redmine project wiki page
320#[derive(Debug, Clone, Builder)]
321#[builder(setter(strip_option))]
322pub struct DeleteProjectWikiPage<'a> {
323    /// the project id or name as it appears in the URL
324    #[builder(setter(into))]
325    project_id_or_name: Cow<'a, str>,
326    /// the title as it appears in the URL
327    #[builder(setter(into))]
328    title: Cow<'a, str>,
329    /// what to do with descendant pages: `null` (default) or `destroy`
330    #[builder(default)]
331    todo: Option<Cow<'a, str>>,
332    /// the id of the wiki page to reassign descendant pages to
333    #[builder(default)]
334    reassign_to_id: Option<u64>,
335}
336
337impl<'a> DeleteProjectWikiPage<'a> {
338    /// Create a builder for the endpoint.
339    #[must_use]
340    pub fn builder() -> DeleteProjectWikiPageBuilder<'a> {
341        DeleteProjectWikiPageBuilder::default()
342    }
343}
344
345impl<'a> Endpoint for DeleteProjectWikiPage<'a> {
346    fn method(&self) -> Method {
347        Method::DELETE
348    }
349
350    fn endpoint(&self) -> Cow<'static, str> {
351        format!(
352            "projects/{}/wiki/{}.json",
353            &self.project_id_or_name, &self.title
354        )
355        .into()
356    }
357
358    fn parameters(&self) -> QueryParams<'_> {
359        let mut params = QueryParams::default();
360        params.push_opt("todo", self.todo.as_ref());
361        params.push_opt("reassign_to_id", self.reassign_to_id);
362        params
363    }
364}
365
366/// The endpoint to delete a specific version of a Redmine project wiki page
367#[derive(Debug, Clone, Builder)]
368#[builder(setter(strip_option))]
369pub struct DeleteProjectWikiPageVersion<'a> {
370    /// the project id or name as it appears in the URL
371    #[builder(setter(into))]
372    project_id_or_name: Cow<'a, str>,
373    /// the title as it appears in the URL
374    #[builder(setter(into))]
375    title: Cow<'a, str>,
376    /// the version to delete
377    version: u64,
378}
379
380impl<'a> DeleteProjectWikiPageVersion<'a> {
381    /// Create a builder for the endpoint.
382    #[must_use]
383    pub fn builder() -> DeleteProjectWikiPageVersionBuilder<'a> {
384        DeleteProjectWikiPageVersionBuilder::default()
385    }
386}
387
388impl<'a> Endpoint for DeleteProjectWikiPageVersion<'a> {
389    fn method(&self) -> Method {
390        Method::DELETE
391    }
392
393    fn endpoint(&self) -> Cow<'static, str> {
394        format!(
395            "projects/{}/wiki/{}/{}/destroy_version.json",
396            &self.project_id_or_name, &self.title, &self.version
397        )
398        .into()
399    }
400}
401
402/// The endpoint to get the annotated view of a Redmine project wiki page
403#[derive(Debug, Clone, Builder)]
404#[builder(setter(strip_option))]
405pub struct GetProjectWikiPageAnnotate<'a> {
406    /// the project id or name as it appears in the URL
407    #[builder(setter(into))]
408    project_id_or_name: Cow<'a, str>,
409    /// the title as it appears in the URL
410    #[builder(setter(into))]
411    title: Cow<'a, str>,
412    /// the version to annotate
413    version: u64,
414}
415
416impl<'a> GetProjectWikiPageAnnotate<'a> {
417    /// Create a builder for the endpoint.
418    #[must_use]
419    pub fn builder() -> GetProjectWikiPageAnnotateBuilder<'a> {
420        GetProjectWikiPageAnnotateBuilder::default()
421    }
422}
423
424impl NoPagination for GetProjectWikiPageAnnotate<'_> {}
425
426impl Endpoint for GetProjectWikiPageAnnotate<'_> {
427    fn method(&self) -> Method {
428        Method::GET
429    }
430
431    fn endpoint(&self) -> Cow<'static, str> {
432        format!(
433            "projects/{}/wiki/{}/annotate.json",
434            &self.project_id_or_name, &self.title
435        )
436        .into()
437    }
438
439    fn parameters(&self) -> QueryParams<'_> {
440        let mut params = QueryParams::default();
441        params.push("v", self.version);
442        params
443    }
444}
445
446/// The endpoint to export a Redmine project wiki page
447#[derive(Debug, Clone, Builder)]
448#[builder(setter(strip_option))]
449pub struct ExportProjectWikiPage<'a> {
450    /// the project id or name as it appears in the URL
451    #[builder(setter(into))]
452    project_id_or_name: Cow<'a, str>,
453    /// the title as it appears in the URL
454    #[builder(setter(into))]
455    title: Cow<'a, str>,
456    /// the version to export
457    #[builder(default)]
458    version: Option<u64>,
459}
460
461impl<'a> ExportProjectWikiPage<'a> {
462    /// Create a builder for the endpoint.
463    #[must_use]
464    pub fn builder() -> ExportProjectWikiPageBuilder<'a> {
465        ExportProjectWikiPageBuilder::default()
466    }
467}
468
469impl NoPagination for ExportProjectWikiPage<'_> {}
470
471impl Endpoint for ExportProjectWikiPage<'_> {
472    fn method(&self) -> Method {
473        Method::GET
474    }
475
476    fn endpoint(&self) -> Cow<'static, str> {
477        format!(
478            "projects/{}/wiki/{}/export.json",
479            &self.project_id_or_name, &self.title
480        )
481        .into()
482    }
483
484    fn parameters(&self) -> QueryParams<'_> {
485        let mut params = QueryParams::default();
486        params.push_opt("v", self.version);
487        params
488    }
489}
490
491/// helper struct for outer layers with a wiki_pages field holding the inner data
492#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
493pub struct WikiPagesWrapper<T> {
494    /// to parse JSON with wiki_pages key
495    pub wiki_pages: Vec<T>,
496}
497
498/// A lot of APIs in Redmine wrap their data in an extra layer, this is a
499/// helper struct for outer layers with a wiki_page field holding the inner data
500#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
501pub struct WikiPageWrapper<T> {
502    /// to parse JSON with an wiki_page key
503    pub wiki_page: T,
504}
505
506#[cfg(test)]
507pub(crate) mod test {
508    use crate::api::projects::{ListProjects, Project, ProjectsInclude, test::PROJECT_LOCK};
509
510    use super::*;
511    use std::error::Error;
512    use tokio::sync::RwLock;
513    use tracing_test::traced_test;
514
515    /// needed so we do not get 404s when listing while
516    /// creating/deleting or creating/updating/deleting
517    pub static PROJECT_WIKI_PAGE_LOCK: RwLock<()> = RwLock::const_new(());
518
519    #[traced_test]
520    #[test]
521    fn test_list_project_wiki_pages() -> Result<(), Box<dyn Error>> {
522        let _r_project = PROJECT_LOCK.blocking_read();
523        let _r_project_wiki_pages = PROJECT_WIKI_PAGE_LOCK.blocking_read();
524        dotenvy::dotenv()?;
525        let redmine = crate::api::Redmine::from_env(
526            reqwest::blocking::Client::builder()
527                .use_rustls_tls()
528                .build()?,
529        )?;
530        let endpoint = ListProjectWikiPages::builder()
531            .project_id_or_name("25")
532            .build()?;
533        redmine.json_response_body::<_, WikiPagesWrapper<WikiPageEssentials>>(&endpoint)?;
534        Ok(())
535    }
536
537    /// this tests if any of the results contain a field we are not deserializing
538    ///
539    /// this will only catch fields we missed if they are part of the response but
540    /// it is better than nothing
541    #[traced_test]
542    #[test]
543    fn test_completeness_wiki_page_essentials() -> Result<(), Box<dyn Error>> {
544        let _r_project = PROJECT_LOCK.blocking_read();
545        let _r_issues = PROJECT_WIKI_PAGE_LOCK.blocking_read();
546        dotenvy::dotenv()?;
547        let redmine = crate::api::Redmine::from_env(
548            reqwest::blocking::Client::builder()
549                .use_rustls_tls()
550                .build()?,
551        )?;
552        let endpoint = ListProjects::builder()
553            .include(vec![ProjectsInclude::EnabledModules])
554            .build()?;
555        let projects = redmine.json_response_body_all_pages_iter::<_, Project>(&endpoint);
556        let mut checked_projects = 0;
557        for project in projects {
558            let project = project?;
559            if !project
560                .enabled_modules
561                .is_some_and(|em| em.iter().any(|m| m.name == "wiki"))
562            {
563                // skip projects where wiki is disabled
564                continue;
565            }
566            let endpoint = ListProjectWikiPages::builder()
567                .project_id_or_name(project.id.to_string())
568                .include(vec![WikiPageInclude::Attachments])
569                .build()?;
570            let Ok(WikiPagesWrapper { wiki_pages: values }) =
571                redmine.json_response_body::<_, WikiPagesWrapper<serde_json::Value>>(&endpoint)
572            else {
573                // TODO: some projects return a 404 for their wiki for unknown reasons even with an
574                //       enabled wiki module. They also do not have a wiki tab so I assume
575                //       it is intentional, they are not closed or archived either
576                //
577                //       Further analysis seems to indicate that this should not happen and is most
578                //       likely an issue resulting from a database state from a buggy old version of
579                //       Redmine
580                continue;
581            };
582            checked_projects += 1;
583            for value in values {
584                let o: WikiPageEssentials = serde_json::from_value(value.clone())?;
585                let reserialized = serde_json::to_value(o)?;
586                assert_eq!(value, reserialized);
587            }
588        }
589        assert!(checked_projects > 0);
590        Ok(())
591    }
592
593    #[traced_test]
594    #[test]
595    fn test_get_project_wiki_page() -> Result<(), Box<dyn Error>> {
596        let _r_project = PROJECT_LOCK.blocking_read();
597        let _r_project_wiki_pages = PROJECT_WIKI_PAGE_LOCK.blocking_read();
598        dotenvy::dotenv()?;
599        let redmine = crate::api::Redmine::from_env(
600            reqwest::blocking::Client::builder()
601                .use_rustls_tls()
602                .build()?,
603        )?;
604        let endpoint = GetProjectWikiPage::builder()
605            .project_id_or_name("25")
606            .title("Administration")
607            .build()?;
608        redmine.json_response_body::<_, WikiPageWrapper<WikiPage>>(&endpoint)?;
609        Ok(())
610    }
611
612    /// this tests if any of the results contain a field we are not deserializing
613    ///
614    /// this will only catch fields we missed if they are part of the response but
615    /// it is better than nothing
616    #[traced_test]
617    #[test]
618    fn test_completeness_wiki_page() -> Result<(), Box<dyn Error>> {
619        let _r_project = PROJECT_LOCK.blocking_read();
620        let _r_issues = PROJECT_WIKI_PAGE_LOCK.blocking_read();
621        dotenvy::dotenv()?;
622        let redmine = crate::api::Redmine::from_env(
623            reqwest::blocking::Client::builder()
624                .use_rustls_tls()
625                .build()?,
626        )?;
627        let endpoint = ListProjects::builder()
628            .include(vec![ProjectsInclude::EnabledModules])
629            .build()?;
630        let projects = redmine.json_response_body_all_pages_iter::<_, Project>(&endpoint);
631        let mut checked_pages = 0;
632        for project in projects {
633            let project = project?;
634            if !project
635                .enabled_modules
636                .is_some_and(|em| em.iter().any(|m| m.name == "wiki"))
637            {
638                // skip projects where wiki is disabled
639                continue;
640            }
641            let endpoint = ListProjectWikiPages::builder()
642                .project_id_or_name(project.id.to_string())
643                .include(vec![WikiPageInclude::Attachments])
644                .build()?;
645            let Ok(WikiPagesWrapper { wiki_pages }) =
646                redmine.json_response_body::<_, WikiPagesWrapper<WikiPageEssentials>>(&endpoint)
647            else {
648                // TODO: some projects return a 404 for their wiki for unknown reasons even with an
649                //       enabled wiki module. They also do not have a wiki tab so I assume
650                //       it is intentional, they are not closed or archived either
651                //
652                //       Further analysis seems to indicate that this should not happen and is most
653                //       likely an issue resulting from a database state from a buggy old version of
654                //       Redmine
655                continue;
656            };
657            checked_pages += 1;
658            for wiki_page in wiki_pages {
659                let endpoint = GetProjectWikiPage::builder()
660                    .project_id_or_name(project.id.to_string())
661                    .title(wiki_page.title)
662                    .include(vec![WikiPageInclude::Attachments])
663                    .build()?;
664                let WikiPageWrapper { wiki_page: value } = redmine
665                    .json_response_body::<_, WikiPageWrapper<serde_json::Value>>(&endpoint)?;
666                let o: WikiPage = serde_json::from_value(value.clone())?;
667                let reserialized = serde_json::to_value(o)?;
668                assert_eq!(value, reserialized);
669            }
670        }
671        assert!(checked_pages > 0);
672        Ok(())
673    }
674
675    #[traced_test]
676    #[test]
677    fn test_get_project_wiki_page_version() -> Result<(), Box<dyn Error>> {
678        let _r_project = PROJECT_LOCK.blocking_read();
679        let _r_project_wiki_pages = PROJECT_WIKI_PAGE_LOCK.blocking_read();
680        dotenvy::dotenv()?;
681        let redmine = crate::api::Redmine::from_env(
682            reqwest::blocking::Client::builder()
683                .use_rustls_tls()
684                .build()?,
685        )?;
686        let endpoint = GetProjectWikiPageVersion::builder()
687            .project_id_or_name("25")
688            .title("Administration")
689            .version(18)
690            .build()?;
691        redmine.json_response_body::<_, WikiPageWrapper<WikiPage>>(&endpoint)?;
692        Ok(())
693    }
694
695    #[traced_test]
696    #[test]
697    fn test_create_update_and_delete_project_wiki_page() -> Result<(), Box<dyn Error>> {
698        let _r_project = PROJECT_LOCK.blocking_read();
699        let _w_project_wiki_pages = PROJECT_WIKI_PAGE_LOCK.blocking_write();
700        dotenvy::dotenv()?;
701        let redmine = crate::api::Redmine::from_env(
702            reqwest::blocking::Client::builder()
703                .use_rustls_tls()
704                .build()?,
705        )?;
706        let endpoint = GetProjectWikiPage::builder()
707            .project_id_or_name("25")
708            .title("CreateWikiPageTest")
709            .build()?;
710        if redmine.ignore_response_body(&endpoint).is_ok() {
711            // left-over from past test that failed to complete
712            let endpoint = DeleteProjectWikiPage::builder()
713                .project_id_or_name("25")
714                .title("CreateWikiPageTest")
715                .build()?;
716            redmine.ignore_response_body(&endpoint)?;
717        }
718        let endpoint = CreateOrUpdateProjectWikiPage::builder()
719            .project_id_or_name("25")
720            .title("CreateWikiPageTest")
721            .text("Test Content")
722            .comments("Create Page Test")
723            .build()?;
724        redmine.ignore_response_body(&endpoint)?;
725        let endpoint = CreateOrUpdateProjectWikiPage::builder()
726            .project_id_or_name("25")
727            .title("CreateWikiPageTest")
728            .text("Test Content Updates")
729            .version(1)
730            .comments("Update Page Test")
731            .build()?;
732        redmine.ignore_response_body(&endpoint)?;
733        let endpoint = DeleteProjectWikiPage::builder()
734            .project_id_or_name("25")
735            .title("CreateWikiPageTest")
736            .build()?;
737        redmine.ignore_response_body(&endpoint)?;
738        Ok(())
739    }
740
741    #[traced_test]
742    #[test]
743    fn test_wiki_page_lifecycle() -> Result<(), Box<dyn Error>> {
744        use crate::api::test_helpers::with_project;
745
746        with_project("test_wiki_page_lifecycle", |redmine, project_id, _| {
747            tracing::debug!("Creating wiki page TestWikiPage");
748            let endpoint = CreateOrUpdateProjectWikiPage::builder()
749                .project_id_or_name(project_id.to_string())
750                .title("TestWikiPage")
751                .text("Test Content")
752                .comments("Create Page Test")
753                .build()?;
754            redmine.ignore_response_body(&endpoint)?;
755
756            tracing::debug!("Verifying existence, content and version of wiki page TestWikiPage");
757            let get_endpoint = GetProjectWikiPage::builder()
758                .project_id_or_name(project_id.to_string())
759                .title("TestWikiPage")
760                .build()?;
761            let WikiPageWrapper { wiki_page } =
762                redmine.json_response_body::<_, WikiPageWrapper<WikiPage>>(&get_endpoint)?;
763            assert_eq!(wiki_page.text, "Test Content");
764            assert_eq!(wiki_page.version, 1);
765
766            tracing::debug!("Updating wiki page TestWikiPage");
767            let update_endpoint = CreateOrUpdateProjectWikiPage::builder()
768                .project_id_or_name(project_id.to_string())
769                .title("TestWikiPage")
770                .text("Test Content Updates")
771                .version(1)
772                .comments("Update Page Test")
773                .build()?;
774            redmine.ignore_response_body(&update_endpoint)?;
775
776            tracing::debug!(
777                "Verifying existence, content and version of updated wiki page TestWikiPage"
778            );
779            let get_endpoint = GetProjectWikiPage::builder()
780                .project_id_or_name(project_id.to_string())
781                .title("TestWikiPage")
782                .build()?;
783            let WikiPageWrapper { wiki_page } =
784                redmine.json_response_body::<_, WikiPageWrapper<WikiPage>>(&get_endpoint)?;
785            assert_eq!(wiki_page.text, "Test Content Updates");
786            assert_eq!(wiki_page.version, 2);
787
788            tracing::debug!("Verifying existence and content of wiki page TestWikiPage version 1");
789            let version_endpoint = GetProjectWikiPageVersion::builder()
790                .project_id_or_name(project_id.to_string())
791                .title("TestWikiPage")
792                .version(1)
793                .build()?;
794            let WikiPageWrapper { wiki_page } =
795                redmine.json_response_body::<_, WikiPageWrapper<WikiPage>>(&version_endpoint)?;
796            assert_eq!(wiki_page.text, "Test Content");
797
798            tracing::debug!("Deleting wiki page TestWikiPage");
799            let delete_endpoint = DeleteProjectWikiPage::builder()
800                .project_id_or_name(project_id.to_string())
801                .title("TestWikiPage")
802                .build()?;
803            redmine.ignore_response_body(&delete_endpoint)?;
804
805            Ok(())
806        })
807    }
808}