Skip to main content

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 ReturnsJsonResponse for ListProjectWikiPages<'_> {}
136impl NoPagination for ListProjectWikiPages<'_> {}
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 Endpoint for ListProjectWikiPages<'_> {
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 Endpoint for DeleteProjectWikiPage<'_> {
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 Endpoint for DeleteProjectWikiPageVersion<'_> {
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 pretty_assertions::assert_eq;
512    use std::error::Error;
513    use tokio::sync::RwLock;
514    use tracing_test::traced_test;
515
516    /// needed so we do not get 404s when listing while
517    /// creating/deleting or creating/updating/deleting
518    pub static PROJECT_WIKI_PAGE_LOCK: RwLock<()> = RwLock::const_new(());
519
520    #[traced_test]
521    #[test]
522    fn test_list_project_wiki_pages() -> Result<(), Box<dyn Error>> {
523        let _r_project = PROJECT_LOCK.blocking_read();
524        let _r_project_wiki_pages = PROJECT_WIKI_PAGE_LOCK.blocking_read();
525        dotenvy::dotenv()?;
526        let redmine = crate::api::Redmine::from_env(
527            reqwest::blocking::Client::builder()
528                .tls_backend_rustls()
529                .build()?,
530        )?;
531        let endpoint = ListProjectWikiPages::builder()
532            .project_id_or_name("25")
533            .build()?;
534        redmine.json_response_body::<_, WikiPagesWrapper<WikiPageEssentials>>(&endpoint)?;
535        Ok(())
536    }
537
538    /// this tests if any of the results contain a field we are not deserializing
539    ///
540    /// this will only catch fields we missed if they are part of the response but
541    /// it is better than nothing
542    #[traced_test]
543    #[test]
544    fn test_completeness_wiki_page_essentials() -> Result<(), Box<dyn Error>> {
545        let _r_project = PROJECT_LOCK.blocking_read();
546        let _r_issues = PROJECT_WIKI_PAGE_LOCK.blocking_read();
547        dotenvy::dotenv()?;
548        let redmine = crate::api::Redmine::from_env(
549            reqwest::blocking::Client::builder()
550                .tls_backend_rustls()
551                .build()?,
552        )?;
553        let endpoint = ListProjects::builder()
554            .include(vec![ProjectsInclude::EnabledModules])
555            .build()?;
556        let projects = redmine.json_response_body_all_pages_iter::<_, Project>(&endpoint);
557        let mut checked_projects = 0;
558        for project in projects {
559            let project = project?;
560            if !project
561                .enabled_modules
562                .is_some_and(|em| em.iter().any(|m| m.name == "wiki"))
563            {
564                // skip projects where wiki is disabled
565                continue;
566            }
567            let endpoint = ListProjectWikiPages::builder()
568                .project_id_or_name(project.id.to_string())
569                .include(vec![WikiPageInclude::Attachments])
570                .build()?;
571            let Ok(WikiPagesWrapper { wiki_pages: values }) =
572                redmine.json_response_body::<_, WikiPagesWrapper<serde_json::Value>>(&endpoint)
573            else {
574                // TODO: some projects return a 404 for their wiki for unknown reasons even with an
575                //       enabled wiki module. They also do not have a wiki tab so I assume
576                //       it is intentional, they are not closed or archived either
577                //
578                //       Further analysis seems to indicate that this should not happen and is most
579                //       likely an issue resulting from a database state from a buggy old version of
580                //       Redmine
581                continue;
582            };
583            checked_projects += 1;
584            for value in values {
585                let o: WikiPageEssentials = serde_json::from_value(value.clone())?;
586                let reserialized = serde_json::to_value(o)?;
587                assert_eq!(value, reserialized);
588            }
589        }
590        assert!(checked_projects > 0);
591        Ok(())
592    }
593
594    #[traced_test]
595    #[test]
596    fn test_get_project_wiki_page() -> Result<(), Box<dyn Error>> {
597        let _r_project = PROJECT_LOCK.blocking_read();
598        let _r_project_wiki_pages = PROJECT_WIKI_PAGE_LOCK.blocking_read();
599        dotenvy::dotenv()?;
600        let redmine = crate::api::Redmine::from_env(
601            reqwest::blocking::Client::builder()
602                .tls_backend_rustls()
603                .build()?,
604        )?;
605        let endpoint = GetProjectWikiPage::builder()
606            .project_id_or_name("25")
607            .title("Administration")
608            .build()?;
609        redmine.json_response_body::<_, WikiPageWrapper<WikiPage>>(&endpoint)?;
610        Ok(())
611    }
612
613    /// this tests if any of the results contain a field we are not deserializing
614    ///
615    /// this will only catch fields we missed if they are part of the response but
616    /// it is better than nothing
617    #[traced_test]
618    #[test]
619    fn test_completeness_wiki_page() -> Result<(), Box<dyn Error>> {
620        let _r_project = PROJECT_LOCK.blocking_read();
621        let _r_issues = PROJECT_WIKI_PAGE_LOCK.blocking_read();
622        dotenvy::dotenv()?;
623        let redmine = crate::api::Redmine::from_env(
624            reqwest::blocking::Client::builder()
625                .tls_backend_rustls()
626                .build()?,
627        )?;
628        let endpoint = ListProjects::builder()
629            .include(vec![ProjectsInclude::EnabledModules])
630            .build()?;
631        let projects = redmine.json_response_body_all_pages_iter::<_, Project>(&endpoint);
632        let mut checked_pages = 0;
633        for project in projects {
634            let project = project?;
635            if !project
636                .enabled_modules
637                .is_some_and(|em| em.iter().any(|m| m.name == "wiki"))
638            {
639                // skip projects where wiki is disabled
640                continue;
641            }
642            let endpoint = ListProjectWikiPages::builder()
643                .project_id_or_name(project.id.to_string())
644                .include(vec![WikiPageInclude::Attachments])
645                .build()?;
646            let Ok(WikiPagesWrapper { wiki_pages }) =
647                redmine.json_response_body::<_, WikiPagesWrapper<WikiPageEssentials>>(&endpoint)
648            else {
649                // TODO: some projects return a 404 for their wiki for unknown reasons even with an
650                //       enabled wiki module. They also do not have a wiki tab so I assume
651                //       it is intentional, they are not closed or archived either
652                //
653                //       Further analysis seems to indicate that this should not happen and is most
654                //       likely an issue resulting from a database state from a buggy old version of
655                //       Redmine
656                continue;
657            };
658            checked_pages += 1;
659            for wiki_page in wiki_pages {
660                let endpoint = GetProjectWikiPage::builder()
661                    .project_id_or_name(project.id.to_string())
662                    .title(wiki_page.title)
663                    .include(vec![WikiPageInclude::Attachments])
664                    .build()?;
665                let WikiPageWrapper { wiki_page: value } = redmine
666                    .json_response_body::<_, WikiPageWrapper<serde_json::Value>>(&endpoint)?;
667                let o: WikiPage = serde_json::from_value(value.clone())?;
668                let reserialized = serde_json::to_value(o)?;
669                assert_eq!(value, reserialized);
670            }
671        }
672        assert!(checked_pages > 0);
673        Ok(())
674    }
675
676    #[traced_test]
677    #[test]
678    fn test_get_project_wiki_page_version() -> Result<(), Box<dyn Error>> {
679        let _r_project = PROJECT_LOCK.blocking_read();
680        let _r_project_wiki_pages = PROJECT_WIKI_PAGE_LOCK.blocking_read();
681        dotenvy::dotenv()?;
682        let redmine = crate::api::Redmine::from_env(
683            reqwest::blocking::Client::builder()
684                .tls_backend_rustls()
685                .build()?,
686        )?;
687        let endpoint = GetProjectWikiPageVersion::builder()
688            .project_id_or_name("25")
689            .title("Administration")
690            .version(18)
691            .build()?;
692        redmine.json_response_body::<_, WikiPageWrapper<WikiPage>>(&endpoint)?;
693        Ok(())
694    }
695
696    #[traced_test]
697    #[test]
698    fn test_create_update_and_delete_project_wiki_page() -> Result<(), Box<dyn Error>> {
699        let _r_project = PROJECT_LOCK.blocking_read();
700        let _w_project_wiki_pages = PROJECT_WIKI_PAGE_LOCK.blocking_write();
701        dotenvy::dotenv()?;
702        let redmine = crate::api::Redmine::from_env(
703            reqwest::blocking::Client::builder()
704                .tls_backend_rustls()
705                .build()?,
706        )?;
707        let endpoint = GetProjectWikiPage::builder()
708            .project_id_or_name("25")
709            .title("CreateWikiPageTest")
710            .build()?;
711        if redmine.ignore_response_body(&endpoint).is_ok() {
712            // left-over from past test that failed to complete
713            let endpoint = DeleteProjectWikiPage::builder()
714                .project_id_or_name("25")
715                .title("CreateWikiPageTest")
716                .build()?;
717            redmine.ignore_response_body(&endpoint)?;
718        }
719        let endpoint = CreateOrUpdateProjectWikiPage::builder()
720            .project_id_or_name("25")
721            .title("CreateWikiPageTest")
722            .text("Test Content")
723            .comments("Create Page Test")
724            .build()?;
725        redmine.ignore_response_body(&endpoint)?;
726        let endpoint = CreateOrUpdateProjectWikiPage::builder()
727            .project_id_or_name("25")
728            .title("CreateWikiPageTest")
729            .text("Test Content Updates")
730            .version(1)
731            .comments("Update Page Test")
732            .build()?;
733        redmine.ignore_response_body(&endpoint)?;
734        let endpoint = DeleteProjectWikiPage::builder()
735            .project_id_or_name("25")
736            .title("CreateWikiPageTest")
737            .build()?;
738        redmine.ignore_response_body(&endpoint)?;
739        Ok(())
740    }
741
742    #[traced_test]
743    #[test]
744    fn test_wiki_page_lifecycle() -> Result<(), Box<dyn Error>> {
745        use crate::api::test_helpers::with_project;
746
747        with_project("test_wiki_page_lifecycle", |redmine, project_id, _| {
748            tracing::debug!("Creating wiki page TestWikiPage");
749            let endpoint = CreateOrUpdateProjectWikiPage::builder()
750                .project_id_or_name(project_id.to_string())
751                .title("TestWikiPage")
752                .text("Test Content")
753                .comments("Create Page Test")
754                .build()?;
755            redmine.ignore_response_body(&endpoint)?;
756
757            tracing::debug!("Verifying existence, content and version of wiki page TestWikiPage");
758            let get_endpoint = GetProjectWikiPage::builder()
759                .project_id_or_name(project_id.to_string())
760                .title("TestWikiPage")
761                .build()?;
762            let WikiPageWrapper { wiki_page } =
763                redmine.json_response_body::<_, WikiPageWrapper<WikiPage>>(&get_endpoint)?;
764            assert_eq!(wiki_page.text, "Test Content");
765            assert_eq!(wiki_page.version, 1);
766
767            tracing::debug!("Updating wiki page TestWikiPage");
768            let update_endpoint = CreateOrUpdateProjectWikiPage::builder()
769                .project_id_or_name(project_id.to_string())
770                .title("TestWikiPage")
771                .text("Test Content Updates")
772                .version(1)
773                .comments("Update Page Test")
774                .build()?;
775            redmine.ignore_response_body(&update_endpoint)?;
776
777            tracing::debug!(
778                "Verifying existence, content and version of updated wiki page TestWikiPage"
779            );
780            let get_endpoint = GetProjectWikiPage::builder()
781                .project_id_or_name(project_id.to_string())
782                .title("TestWikiPage")
783                .build()?;
784            let WikiPageWrapper { wiki_page } =
785                redmine.json_response_body::<_, WikiPageWrapper<WikiPage>>(&get_endpoint)?;
786            assert_eq!(wiki_page.text, "Test Content Updates");
787            assert_eq!(wiki_page.version, 2);
788
789            tracing::debug!("Verifying existence and content of wiki page TestWikiPage version 1");
790            let version_endpoint = GetProjectWikiPageVersion::builder()
791                .project_id_or_name(project_id.to_string())
792                .title("TestWikiPage")
793                .version(1)
794                .build()?;
795            let WikiPageWrapper { wiki_page } =
796                redmine.json_response_body::<_, WikiPageWrapper<WikiPage>>(&version_endpoint)?;
797            assert_eq!(wiki_page.text, "Test Content");
798
799            tracing::debug!("Deleting wiki page TestWikiPage");
800            let delete_endpoint = DeleteProjectWikiPage::builder()
801                .project_id_or_name(project_id.to_string())
802                .title("TestWikiPage")
803                .build()?;
804            redmine.ignore_response_body(&delete_endpoint)?;
805
806            Ok(())
807        })
808    }
809}