Skip to main content

omni_dev/atlassian/
confluence_api.rs

1//! Confluence Cloud REST API v2 implementation of [`AtlassianApi`].
2//!
3//! Uses the Confluence REST API v2 to read and write pages.
4//! Pages are fetched with ADF body format and updated with version
5//! number increments for optimistic locking.
6
7use std::future::Future;
8use std::pin::Pin;
9
10use anyhow::{Context, Result};
11use serde::{Deserialize, Serialize};
12use tracing::debug;
13
14use crate::atlassian::adf::AdfDocument;
15use crate::atlassian::api::{AtlassianApi, ContentItem, ContentMetadata};
16use crate::atlassian::client::AtlassianClient;
17use crate::atlassian::error::AtlassianError;
18
19/// Confluence Cloud REST API v2 backend.
20pub struct ConfluenceApi {
21    client: AtlassianClient,
22}
23
24impl ConfluenceApi {
25    /// Creates a new Confluence API backend.
26    pub fn new(client: AtlassianClient) -> Self {
27        Self { client }
28    }
29}
30
31// ── Internal API response structs ───────────────────────────────────
32
33#[derive(Deserialize)]
34struct ConfluencePageResponse {
35    id: String,
36    title: String,
37    status: String,
38    #[serde(rename = "spaceId")]
39    space_id: String,
40    version: Option<ConfluenceVersion>,
41    body: Option<ConfluenceBody>,
42    #[serde(rename = "parentId")]
43    parent_id: Option<String>,
44}
45
46#[derive(Deserialize)]
47struct ConfluenceVersion {
48    number: u32,
49}
50
51#[derive(Deserialize)]
52struct ConfluenceBody {
53    atlas_doc_format: Option<ConfluenceAtlasDoc>,
54}
55
56#[derive(Deserialize)]
57struct ConfluenceAtlasDoc {
58    value: String,
59}
60
61// ── Space lookup ────────────────────────────────────────────────────
62
63#[derive(Deserialize)]
64struct ConfluenceSpaceResponse {
65    key: String,
66}
67
68#[derive(Deserialize)]
69struct ConfluenceSpacesSearchResponse {
70    results: Vec<ConfluenceSpaceSearchEntry>,
71}
72
73#[derive(Deserialize)]
74struct ConfluenceSpaceSearchEntry {
75    id: String,
76}
77
78// ── Children response ──────────────────────────────────────────────
79
80#[derive(Deserialize)]
81struct ConfluenceChildrenResponse {
82    results: Vec<ConfluenceChildEntry>,
83    #[serde(rename = "_links", default)]
84    links: Option<ConfluenceChildrenLinks>,
85}
86
87#[derive(Deserialize)]
88struct ConfluenceChildEntry {
89    id: String,
90    title: String,
91}
92
93#[derive(Deserialize)]
94struct ConfluenceChildrenLinks {
95    next: Option<String>,
96}
97
98/// A child page returned from the children API.
99#[derive(Debug, Clone)]
100pub struct ChildPage {
101    /// Page ID.
102    pub id: String,
103    /// Page title.
104    pub title: String,
105}
106
107// ── Create request ─────────────────────────────────────────────────
108
109#[derive(Serialize)]
110struct ConfluenceCreateRequest {
111    #[serde(rename = "spaceId")]
112    space_id: String,
113    title: String,
114    body: ConfluenceUpdateBody,
115    #[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
116    parent_id: Option<String>,
117    status: String,
118}
119
120#[derive(Deserialize)]
121struct ConfluenceCreateResponse {
122    id: String,
123}
124
125// ── Update request ──────────────────────────────────────────────────
126
127#[derive(Serialize)]
128struct ConfluenceUpdateRequest {
129    id: String,
130    status: String,
131    title: String,
132    body: ConfluenceUpdateBody,
133    version: ConfluenceUpdateVersion,
134}
135
136#[derive(Serialize)]
137struct ConfluenceUpdateBody {
138    representation: String,
139    value: String,
140}
141
142#[derive(Serialize)]
143struct ConfluenceUpdateVersion {
144    number: u32,
145    message: Option<String>,
146}
147
148impl AtlassianApi for ConfluenceApi {
149    fn get_content<'a>(
150        &'a self,
151        id: &'a str,
152    ) -> Pin<Box<dyn Future<Output = Result<ContentItem>> + Send + 'a>> {
153        Box::pin(async move {
154            let url = format!(
155                "{}/wiki/api/v2/pages/{}?body-format=atlas_doc_format",
156                self.client.instance_url(),
157                id
158            );
159
160            let response = self
161                .client
162                .get_json(&url)
163                .await
164                .context("Failed to fetch Confluence page")?;
165
166            if !response.status().is_success() {
167                let status = response.status().as_u16();
168                let body = response.text().await.unwrap_or_default();
169                return Err(AtlassianError::ApiRequestFailed { status, body }.into());
170            }
171
172            let page: ConfluencePageResponse = response
173                .json()
174                .await
175                .context("Failed to parse Confluence page response")?;
176
177            debug!(
178                page_id = page.id,
179                title = page.title,
180                "Fetched Confluence page"
181            );
182
183            // Confluence returns ADF as a JSON string — parse it to a Value.
184            let body_adf = if let Some(body) = &page.body {
185                if let Some(atlas_doc) = &body.atlas_doc_format {
186                    if tracing::enabled!(tracing::Level::TRACE) {
187                        if let Ok(pretty) =
188                            serde_json::from_str::<serde_json::Value>(&atlas_doc.value)
189                                .and_then(|v| serde_json::to_string_pretty(&v))
190                        {
191                            tracing::trace!("Original ADF from Confluence:\n{pretty}");
192                        }
193                    }
194                    Some(
195                        serde_json::from_str(&atlas_doc.value)
196                            .context("Failed to parse ADF from Confluence body")?,
197                    )
198                } else {
199                    None
200                }
201            } else {
202                None
203            };
204
205            // Resolve space key from space ID.
206            let space_key = self.resolve_space_key(&page.space_id).await?;
207
208            Ok(ContentItem {
209                id: page.id,
210                title: page.title,
211                body_adf,
212                metadata: ContentMetadata::Confluence {
213                    space_key,
214                    status: Some(page.status),
215                    version: page.version.map(|v| v.number),
216                    parent_id: page.parent_id,
217                },
218            })
219        })
220    }
221
222    fn update_content<'a>(
223        &'a self,
224        id: &'a str,
225        body_adf: &'a AdfDocument,
226        title: Option<&'a str>,
227    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>> {
228        Box::pin(async move {
229            // Fetch current page to get version number and title.
230            let current = self.get_content(id).await?;
231            let current_version = match &current.metadata {
232                ContentMetadata::Confluence { version, .. } => version.unwrap_or(1),
233                ContentMetadata::Jira { .. } => 1,
234            };
235            let current_title = current.title;
236
237            let adf_json =
238                serde_json::to_string(body_adf).context("Failed to serialize ADF document")?;
239
240            debug!(
241                page_id = id,
242                version = current_version + 1,
243                adf_bytes = adf_json.len(),
244                "Updating Confluence page"
245            );
246            if tracing::enabled!(tracing::Level::TRACE) {
247                let pretty = serde_json::to_string_pretty(body_adf)
248                    .unwrap_or_else(|e| format!("<serialization error: {e}>"));
249                tracing::trace!("ADF body for update:\n{pretty}");
250            }
251
252            let update = ConfluenceUpdateRequest {
253                id: id.to_string(),
254                status: "current".to_string(),
255                title: title.unwrap_or(&current_title).to_string(),
256                body: ConfluenceUpdateBody {
257                    representation: "atlas_doc_format".to_string(),
258                    value: adf_json,
259                },
260                version: ConfluenceUpdateVersion {
261                    number: current_version + 1,
262                    message: None,
263                },
264            };
265
266            let url = format!("{}/wiki/api/v2/pages/{}", self.client.instance_url(), id);
267
268            let response = self
269                .client
270                .put_json(&url, &update)
271                .await
272                .context("Failed to update Confluence page")?;
273
274            if !response.status().is_success() {
275                let status = response.status().as_u16();
276                let body = response.text().await.unwrap_or_default();
277                return Err(AtlassianError::ApiRequestFailed { status, body }.into());
278            }
279
280            Ok(())
281        })
282    }
283
284    fn verify_auth<'a>(&'a self) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>> {
285        // Reuse the JIRA /myself endpoint — same Atlassian Cloud instance.
286        Box::pin(async move {
287            let user = self.client.get_myself().await?;
288            Ok(user.display_name)
289        })
290    }
291
292    fn backend_name(&self) -> &'static str {
293        "confluence"
294    }
295}
296
297impl ConfluenceApi {
298    /// Resolves a space key to a space ID via the Confluence API.
299    pub async fn resolve_space_id(&self, space_key: &str) -> Result<String> {
300        let url = format!(
301            "{}/wiki/api/v2/spaces?keys={}",
302            self.client.instance_url(),
303            space_key
304        );
305
306        let response = self
307            .client
308            .get_json(&url)
309            .await
310            .context("Failed to search Confluence spaces")?;
311
312        if !response.status().is_success() {
313            let status = response.status().as_u16();
314            let body = response.text().await.unwrap_or_default();
315            return Err(AtlassianError::ApiRequestFailed { status, body }.into());
316        }
317
318        let resp: ConfluenceSpacesSearchResponse = response
319            .json()
320            .await
321            .context("Failed to parse Confluence spaces response")?;
322
323        resp.results
324            .first()
325            .map(|s| s.id.clone())
326            .ok_or_else(|| anyhow::anyhow!("Space with key \"{space_key}\" not found"))
327    }
328
329    /// Creates a new Confluence page.
330    pub async fn create_page(
331        &self,
332        space_key: &str,
333        title: &str,
334        body_adf: &AdfDocument,
335        parent_id: Option<&str>,
336    ) -> Result<String> {
337        let space_id = self.resolve_space_id(space_key).await?;
338
339        let adf_json =
340            serde_json::to_string(body_adf).context("Failed to serialize ADF document")?;
341
342        let request = ConfluenceCreateRequest {
343            space_id,
344            title: title.to_string(),
345            body: ConfluenceUpdateBody {
346                representation: "atlas_doc_format".to_string(),
347                value: adf_json,
348            },
349            parent_id: parent_id.map(String::from),
350            status: "current".to_string(),
351        };
352
353        let url = format!("{}/wiki/api/v2/pages", self.client.instance_url());
354
355        let response = self
356            .client
357            .post_json(&url, &request)
358            .await
359            .context("Failed to create Confluence page")?;
360
361        if !response.status().is_success() {
362            let status = response.status().as_u16();
363            let body = response.text().await.unwrap_or_default();
364            return Err(AtlassianError::ApiRequestFailed { status, body }.into());
365        }
366
367        let resp: ConfluenceCreateResponse = response
368            .json()
369            .await
370            .context("Failed to parse Confluence create response")?;
371
372        Ok(resp.id)
373    }
374
375    /// Deletes a Confluence page.
376    pub async fn delete_page(&self, id: &str, purge: bool) -> Result<()> {
377        let mut url = format!("{}/wiki/api/v2/pages/{}", self.client.instance_url(), id);
378        if purge {
379            url.push_str("?purge=true");
380        }
381
382        let response = self.client.delete(&url).await?;
383
384        if !response.status().is_success() {
385            let status = response.status().as_u16();
386            let body = response.text().await.unwrap_or_default();
387            if status == 404 {
388                anyhow::bail!(
389                    "Page {id} not found or insufficient permissions. \
390                     Confluence returns 404 when the API user lacks space-level delete permission. \
391                     Check Space Settings > Permissions."
392                );
393            }
394            return Err(AtlassianError::ApiRequestFailed { status, body }.into());
395        }
396
397        Ok(())
398    }
399
400    /// Fetches all child pages of a given page, handling pagination.
401    ///
402    /// Uses the v1 content API (`/wiki/rest/api/content/{id}/child/page`)
403    /// which is more widely supported than the v2 children endpoint.
404    pub async fn get_children(&self, page_id: &str) -> Result<Vec<ChildPage>> {
405        let mut all_children = Vec::new();
406        let mut url = format!(
407            "{}/wiki/rest/api/content/{}/child/page?limit=50",
408            self.client.instance_url(),
409            page_id
410        );
411
412        loop {
413            let response = self
414                .client
415                .get_json(&url)
416                .await
417                .context("Failed to fetch child pages")?;
418
419            if !response.status().is_success() {
420                let status = response.status().as_u16();
421                let body = response.text().await.unwrap_or_default();
422                return Err(AtlassianError::ApiRequestFailed { status, body }.into());
423            }
424
425            let resp: ConfluenceChildrenResponse = response
426                .json()
427                .await
428                .context("Failed to parse children response")?;
429
430            let page_count = resp.results.len();
431            for child in resp.results {
432                all_children.push(ChildPage {
433                    id: child.id,
434                    title: child.title,
435                });
436            }
437
438            match resp.links.and_then(|l| l.next) {
439                Some(next_path) if page_count > 0 => {
440                    url = format!("{}{}", self.client.instance_url(), next_path);
441                }
442                _ => break,
443            }
444        }
445
446        Ok(all_children)
447    }
448
449    /// Resolves a space ID to a space key via the Confluence API.
450    async fn resolve_space_key(&self, space_id: &str) -> Result<String> {
451        let url = format!(
452            "{}/wiki/api/v2/spaces/{}",
453            self.client.instance_url(),
454            space_id
455        );
456
457        let response = self
458            .client
459            .get_json(&url)
460            .await
461            .context("Failed to fetch Confluence space")?;
462
463        if !response.status().is_success() {
464            // Fall back to using the space ID as key if lookup fails.
465            return Ok(space_id.to_string());
466        }
467
468        let space: ConfluenceSpaceResponse = response
469            .json()
470            .await
471            .context("Failed to parse Confluence space response")?;
472
473        Ok(space.key)
474    }
475}
476
477#[cfg(test)]
478#[allow(clippy::unwrap_used, clippy::expect_used)]
479mod tests {
480    use super::*;
481
482    #[test]
483    fn confluence_api_backend_name() {
484        let client =
485            AtlassianClient::new("https://org.atlassian.net", "user@test.com", "token").unwrap();
486        let api = ConfluenceApi::new(client);
487        assert_eq!(api.backend_name(), "confluence");
488    }
489
490    #[test]
491    fn confluence_page_response_deserialization() {
492        let json = r#"{
493            "id": "12345",
494            "title": "Test Page",
495            "status": "current",
496            "spaceId": "98765",
497            "version": {"number": 3},
498            "body": {
499                "atlas_doc_format": {
500                    "value": "{\"version\":1,\"type\":\"doc\",\"content\":[]}"
501                }
502            },
503            "parentId": "11111"
504        }"#;
505        let page: ConfluencePageResponse = serde_json::from_str(json).unwrap();
506        assert_eq!(page.id, "12345");
507        assert_eq!(page.title, "Test Page");
508        assert_eq!(page.status, "current");
509        assert_eq!(page.space_id, "98765");
510        assert_eq!(page.version.unwrap().number, 3);
511        assert_eq!(page.parent_id.as_deref(), Some("11111"));
512
513        let body = page.body.unwrap();
514        let atlas_doc = body.atlas_doc_format.unwrap();
515        let adf: serde_json::Value = serde_json::from_str(&atlas_doc.value).unwrap();
516        assert_eq!(adf["version"], 1);
517        assert_eq!(adf["type"], "doc");
518    }
519
520    #[test]
521    fn confluence_page_response_minimal() {
522        let json = r#"{
523            "id": "99",
524            "title": "Minimal",
525            "status": "draft",
526            "spaceId": "1"
527        }"#;
528        let page: ConfluencePageResponse = serde_json::from_str(json).unwrap();
529        assert_eq!(page.id, "99");
530        assert!(page.version.is_none());
531        assert!(page.body.is_none());
532        assert!(page.parent_id.is_none());
533    }
534
535    #[test]
536    fn confluence_update_request_serialization() {
537        let req = ConfluenceUpdateRequest {
538            id: "12345".to_string(),
539            status: "current".to_string(),
540            title: "Updated Title".to_string(),
541            body: ConfluenceUpdateBody {
542                representation: "atlas_doc_format".to_string(),
543                value: r#"{"version":1,"type":"doc","content":[]}"#.to_string(),
544            },
545            version: ConfluenceUpdateVersion {
546                number: 4,
547                message: None,
548            },
549        };
550
551        let json = serde_json::to_value(&req).unwrap();
552        assert_eq!(json["id"], "12345");
553        assert_eq!(json["status"], "current");
554        assert_eq!(json["title"], "Updated Title");
555        assert_eq!(json["body"]["representation"], "atlas_doc_format");
556        assert_eq!(json["version"]["number"], 4);
557    }
558
559    #[test]
560    fn confluence_update_version_with_message() {
561        let req = ConfluenceUpdateRequest {
562            id: "1".to_string(),
563            status: "current".to_string(),
564            title: "T".to_string(),
565            body: ConfluenceUpdateBody {
566                representation: "atlas_doc_format".to_string(),
567                value: "{}".to_string(),
568            },
569            version: ConfluenceUpdateVersion {
570                number: 2,
571                message: Some("Updated via API".to_string()),
572            },
573        };
574        let json = serde_json::to_value(&req).unwrap();
575        assert_eq!(json["version"]["message"], "Updated via API");
576    }
577
578    #[test]
579    fn confluence_space_response_deserialization() {
580        let json = r#"{"key": "ENG"}"#;
581        let space: ConfluenceSpaceResponse = serde_json::from_str(json).unwrap();
582        assert_eq!(space.key, "ENG");
583    }
584
585    /// Helper to set up a wiremock server with the Confluence page and space endpoints.
586    async fn setup_confluence_mock() -> (wiremock::MockServer, ConfluenceApi) {
587        let server = wiremock::MockServer::start().await;
588
589        let page_json = serde_json::json!({
590            "id": "12345",
591            "title": "Test Page",
592            "status": "current",
593            "spaceId": "98765",
594            "version": {"number": 3},
595            "body": {
596                "atlas_doc_format": {
597                    "value": "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"Hello\"}]}]}"
598                }
599            },
600            "parentId": "11111"
601        });
602
603        wiremock::Mock::given(wiremock::matchers::method("GET"))
604            .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
605            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&page_json))
606            .mount(&server)
607            .await;
608
609        wiremock::Mock::given(wiremock::matchers::method("GET"))
610            .and(wiremock::matchers::path("/wiki/api/v2/spaces/98765"))
611            .respond_with(
612                wiremock::ResponseTemplate::new(200)
613                    .set_body_json(serde_json::json!({"key": "ENG"})),
614            )
615            .mount(&server)
616            .await;
617
618        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
619        let api = ConfluenceApi::new(client);
620
621        (server, api)
622    }
623
624    #[tokio::test]
625    async fn get_content_success() {
626        use crate::atlassian::api::{AtlassianApi, ContentMetadata};
627
628        let (_server, api) = setup_confluence_mock().await;
629        let item = api.get_content("12345").await.unwrap();
630
631        assert_eq!(item.id, "12345");
632        assert_eq!(item.title, "Test Page");
633        assert!(item.body_adf.is_some());
634        match &item.metadata {
635            ContentMetadata::Confluence {
636                space_key,
637                status,
638                version,
639                parent_id,
640            } => {
641                assert_eq!(space_key, "ENG");
642                assert_eq!(status.as_deref(), Some("current"));
643                assert_eq!(*version, Some(3));
644                assert_eq!(parent_id.as_deref(), Some("11111"));
645            }
646            ContentMetadata::Jira { .. } => panic!("Expected Confluence metadata"),
647        }
648    }
649
650    #[tokio::test]
651    async fn get_content_api_error() {
652        use crate::atlassian::api::AtlassianApi;
653
654        let server = wiremock::MockServer::start().await;
655
656        wiremock::Mock::given(wiremock::matchers::method("GET"))
657            .and(wiremock::matchers::path("/wiki/api/v2/pages/99999"))
658            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
659            .mount(&server)
660            .await;
661
662        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
663        let api = ConfluenceApi::new(client);
664        let err = api.get_content("99999").await.unwrap_err();
665        assert!(err.to_string().contains("404"));
666    }
667
668    #[tokio::test]
669    async fn get_content_no_body() {
670        use crate::atlassian::api::AtlassianApi;
671
672        let server = wiremock::MockServer::start().await;
673
674        wiremock::Mock::given(wiremock::matchers::method("GET"))
675            .and(wiremock::matchers::path("/wiki/api/v2/pages/55555"))
676            .respond_with(
677                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
678                    "id": "55555",
679                    "title": "No Body",
680                    "status": "draft",
681                    "spaceId": "11111"
682                })),
683            )
684            .mount(&server)
685            .await;
686
687        wiremock::Mock::given(wiremock::matchers::method("GET"))
688            .and(wiremock::matchers::path("/wiki/api/v2/spaces/11111"))
689            .respond_with(
690                wiremock::ResponseTemplate::new(200)
691                    .set_body_json(serde_json::json!({"key": "DEV"})),
692            )
693            .mount(&server)
694            .await;
695
696        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
697        let api = ConfluenceApi::new(client);
698        let item = api.get_content("55555").await.unwrap();
699        assert!(item.body_adf.is_none());
700    }
701
702    #[tokio::test]
703    async fn update_content_success() {
704        use crate::atlassian::api::AtlassianApi;
705
706        let (server, api) = setup_confluence_mock().await;
707
708        wiremock::Mock::given(wiremock::matchers::method("PUT"))
709            .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
710            .respond_with(wiremock::ResponseTemplate::new(200))
711            .mount(&server)
712            .await;
713
714        let adf = AdfDocument::new();
715        let result = api.update_content("12345", &adf, Some("New Title")).await;
716        assert!(result.is_ok());
717    }
718
719    #[tokio::test]
720    async fn update_content_api_error() {
721        use crate::atlassian::api::AtlassianApi;
722
723        let (server, api) = setup_confluence_mock().await;
724
725        wiremock::Mock::given(wiremock::matchers::method("PUT"))
726            .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
727            .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
728            .mount(&server)
729            .await;
730
731        let adf = AdfDocument::new();
732        let err = api.update_content("12345", &adf, None).await.unwrap_err();
733        assert!(err.to_string().contains("403"));
734    }
735
736    #[tokio::test]
737    async fn verify_auth_success() {
738        use crate::atlassian::api::AtlassianApi;
739
740        let server = wiremock::MockServer::start().await;
741
742        wiremock::Mock::given(wiremock::matchers::method("GET"))
743            .and(wiremock::matchers::path("/rest/api/3/myself"))
744            .respond_with(
745                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
746                    "displayName": "Alice",
747                    "accountId": "abc123"
748                })),
749            )
750            .mount(&server)
751            .await;
752
753        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
754        let api = ConfluenceApi::new(client);
755        let name = api.verify_auth().await.unwrap();
756        assert_eq!(name, "Alice");
757    }
758
759    #[tokio::test]
760    async fn resolve_space_id_success() {
761        let server = wiremock::MockServer::start().await;
762
763        wiremock::Mock::given(wiremock::matchers::method("GET"))
764            .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
765            .respond_with(
766                wiremock::ResponseTemplate::new(200)
767                    .set_body_json(serde_json::json!({"results": [{"id": "98765"}]})),
768            )
769            .mount(&server)
770            .await;
771
772        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
773        let api = ConfluenceApi::new(client);
774        let id = api.resolve_space_id("ENG").await.unwrap();
775        assert_eq!(id, "98765");
776    }
777
778    #[tokio::test]
779    async fn resolve_space_id_not_found() {
780        let server = wiremock::MockServer::start().await;
781
782        wiremock::Mock::given(wiremock::matchers::method("GET"))
783            .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
784            .respond_with(
785                wiremock::ResponseTemplate::new(200)
786                    .set_body_json(serde_json::json!({"results": []})),
787            )
788            .mount(&server)
789            .await;
790
791        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
792        let api = ConfluenceApi::new(client);
793        let err = api.resolve_space_id("NOPE").await.unwrap_err();
794        assert!(err.to_string().contains("not found"));
795    }
796
797    #[tokio::test]
798    async fn resolve_space_id_api_error() {
799        let server = wiremock::MockServer::start().await;
800
801        wiremock::Mock::given(wiremock::matchers::method("GET"))
802            .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
803            .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
804            .mount(&server)
805            .await;
806
807        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
808        let api = ConfluenceApi::new(client);
809        let err = api.resolve_space_id("ENG").await.unwrap_err();
810        assert!(err.to_string().contains("403"));
811    }
812
813    #[tokio::test]
814    async fn create_page_success() {
815        let server = wiremock::MockServer::start().await;
816
817        // Space lookup
818        wiremock::Mock::given(wiremock::matchers::method("GET"))
819            .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
820            .respond_with(
821                wiremock::ResponseTemplate::new(200)
822                    .set_body_json(serde_json::json!({"results": [{"id": "98765"}]})),
823            )
824            .mount(&server)
825            .await;
826
827        // Create page
828        wiremock::Mock::given(wiremock::matchers::method("POST"))
829            .and(wiremock::matchers::path("/wiki/api/v2/pages"))
830            .respond_with(
831                wiremock::ResponseTemplate::new(200)
832                    .set_body_json(serde_json::json!({"id": "54321"})),
833            )
834            .mount(&server)
835            .await;
836
837        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
838        let api = ConfluenceApi::new(client);
839        let adf = AdfDocument::new();
840        let id = api
841            .create_page("ENG", "New Page", &adf, None)
842            .await
843            .unwrap();
844        assert_eq!(id, "54321");
845    }
846
847    #[tokio::test]
848    async fn create_page_with_parent() {
849        let server = wiremock::MockServer::start().await;
850
851        wiremock::Mock::given(wiremock::matchers::method("GET"))
852            .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
853            .respond_with(
854                wiremock::ResponseTemplate::new(200)
855                    .set_body_json(serde_json::json!({"results": [{"id": "98765"}]})),
856            )
857            .mount(&server)
858            .await;
859
860        wiremock::Mock::given(wiremock::matchers::method("POST"))
861            .and(wiremock::matchers::path("/wiki/api/v2/pages"))
862            .respond_with(
863                wiremock::ResponseTemplate::new(200)
864                    .set_body_json(serde_json::json!({"id": "54322"})),
865            )
866            .mount(&server)
867            .await;
868
869        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
870        let api = ConfluenceApi::new(client);
871        let adf = AdfDocument::new();
872        let id = api
873            .create_page("ENG", "Child Page", &adf, Some("11111"))
874            .await
875            .unwrap();
876        assert_eq!(id, "54322");
877    }
878
879    #[tokio::test]
880    async fn create_page_api_error() {
881        let server = wiremock::MockServer::start().await;
882
883        wiremock::Mock::given(wiremock::matchers::method("GET"))
884            .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
885            .respond_with(
886                wiremock::ResponseTemplate::new(200)
887                    .set_body_json(serde_json::json!({"results": [{"id": "98765"}]})),
888            )
889            .mount(&server)
890            .await;
891
892        wiremock::Mock::given(wiremock::matchers::method("POST"))
893            .and(wiremock::matchers::path("/wiki/api/v2/pages"))
894            .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Bad Request"))
895            .mount(&server)
896            .await;
897
898        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
899        let api = ConfluenceApi::new(client);
900        let adf = AdfDocument::new();
901        let err = api
902            .create_page("ENG", "Fail", &adf, None)
903            .await
904            .unwrap_err();
905        assert!(err.to_string().contains("400"));
906    }
907
908    #[tokio::test]
909    async fn delete_page_success() {
910        let server = wiremock::MockServer::start().await;
911
912        wiremock::Mock::given(wiremock::matchers::method("DELETE"))
913            .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
914            .respond_with(wiremock::ResponseTemplate::new(204))
915            .expect(1)
916            .mount(&server)
917            .await;
918
919        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
920        let api = ConfluenceApi::new(client);
921        let result = api.delete_page("12345", false).await;
922        assert!(result.is_ok());
923    }
924
925    #[tokio::test]
926    async fn delete_page_with_purge() {
927        let server = wiremock::MockServer::start().await;
928
929        wiremock::Mock::given(wiremock::matchers::method("DELETE"))
930            .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
931            .and(wiremock::matchers::query_param("purge", "true"))
932            .respond_with(wiremock::ResponseTemplate::new(204))
933            .expect(1)
934            .mount(&server)
935            .await;
936
937        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
938        let api = ConfluenceApi::new(client);
939        let result = api.delete_page("12345", true).await;
940        assert!(result.is_ok());
941    }
942
943    #[tokio::test]
944    async fn delete_page_not_found_hints_permissions() {
945        let server = wiremock::MockServer::start().await;
946
947        wiremock::Mock::given(wiremock::matchers::method("DELETE"))
948            .and(wiremock::matchers::path("/wiki/api/v2/pages/99999"))
949            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
950            .expect(1)
951            .mount(&server)
952            .await;
953
954        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
955        let api = ConfluenceApi::new(client);
956        let err = api.delete_page("99999", false).await.unwrap_err();
957        let msg = err.to_string();
958        assert!(msg.contains("not found or insufficient permissions"));
959        assert!(msg.contains("Space Settings"));
960    }
961
962    #[tokio::test]
963    async fn delete_page_forbidden() {
964        let server = wiremock::MockServer::start().await;
965
966        wiremock::Mock::given(wiremock::matchers::method("DELETE"))
967            .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
968            .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
969            .expect(1)
970            .mount(&server)
971            .await;
972
973        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
974        let api = ConfluenceApi::new(client);
975        let err = api.delete_page("12345", false).await.unwrap_err();
976        assert!(err.to_string().contains("403"));
977    }
978
979    #[tokio::test]
980    async fn get_children_success() {
981        let server = wiremock::MockServer::start().await;
982
983        wiremock::Mock::given(wiremock::matchers::method("GET"))
984            .and(wiremock::matchers::path(
985                "/wiki/rest/api/content/12345/child/page",
986            ))
987            .respond_with(
988                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
989                    "results": [
990                        {"id": "111", "title": "Child One"},
991                        {"id": "222", "title": "Child Two"}
992                    ]
993                })),
994            )
995            .expect(1)
996            .mount(&server)
997            .await;
998
999        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1000        let api = ConfluenceApi::new(client);
1001        let children = api.get_children("12345").await.unwrap();
1002
1003        assert_eq!(children.len(), 2);
1004        assert_eq!(children[0].id, "111");
1005        assert_eq!(children[0].title, "Child One");
1006        assert_eq!(children[1].id, "222");
1007    }
1008
1009    #[tokio::test]
1010    async fn get_children_empty() {
1011        let server = wiremock::MockServer::start().await;
1012
1013        wiremock::Mock::given(wiremock::matchers::method("GET"))
1014            .and(wiremock::matchers::path(
1015                "/wiki/rest/api/content/12345/child/page",
1016            ))
1017            .respond_with(
1018                wiremock::ResponseTemplate::new(200)
1019                    .set_body_json(serde_json::json!({"results": []})),
1020            )
1021            .expect(1)
1022            .mount(&server)
1023            .await;
1024
1025        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1026        let api = ConfluenceApi::new(client);
1027        let children = api.get_children("12345").await.unwrap();
1028        assert!(children.is_empty());
1029    }
1030
1031    #[tokio::test]
1032    async fn get_children_api_error() {
1033        let server = wiremock::MockServer::start().await;
1034
1035        wiremock::Mock::given(wiremock::matchers::method("GET"))
1036            .and(wiremock::matchers::path(
1037                "/wiki/rest/api/content/99999/child/page",
1038            ))
1039            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
1040            .expect(1)
1041            .mount(&server)
1042            .await;
1043
1044        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1045        let api = ConfluenceApi::new(client);
1046        let err = api.get_children("99999").await.unwrap_err();
1047        assert!(err.to_string().contains("404"));
1048    }
1049
1050    #[tokio::test]
1051    async fn resolve_space_key_fallback_on_error() {
1052        let server = wiremock::MockServer::start().await;
1053
1054        wiremock::Mock::given(wiremock::matchers::method("GET"))
1055            .and(wiremock::matchers::path("/wiki/api/v2/spaces/unknown"))
1056            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
1057            .mount(&server)
1058            .await;
1059
1060        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1061        let api = ConfluenceApi::new(client);
1062        let key = api.resolve_space_key("unknown").await.unwrap();
1063        // Falls back to the space ID when lookup fails
1064        assert_eq!(key, "unknown");
1065    }
1066}