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// ── Create request ─────────────────────────────────────────────────
79
80#[derive(Serialize)]
81struct ConfluenceCreateRequest {
82    #[serde(rename = "spaceId")]
83    space_id: String,
84    title: String,
85    body: ConfluenceUpdateBody,
86    #[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
87    parent_id: Option<String>,
88    status: String,
89}
90
91#[derive(Deserialize)]
92struct ConfluenceCreateResponse {
93    id: String,
94}
95
96// ── Update request ──────────────────────────────────────────────────
97
98#[derive(Serialize)]
99struct ConfluenceUpdateRequest {
100    id: String,
101    status: String,
102    title: String,
103    body: ConfluenceUpdateBody,
104    version: ConfluenceUpdateVersion,
105}
106
107#[derive(Serialize)]
108struct ConfluenceUpdateBody {
109    representation: String,
110    value: String,
111}
112
113#[derive(Serialize)]
114struct ConfluenceUpdateVersion {
115    number: u32,
116    message: Option<String>,
117}
118
119impl AtlassianApi for ConfluenceApi {
120    fn get_content<'a>(
121        &'a self,
122        id: &'a str,
123    ) -> Pin<Box<dyn Future<Output = Result<ContentItem>> + Send + 'a>> {
124        Box::pin(async move {
125            let url = format!(
126                "{}/wiki/api/v2/pages/{}?body-format=atlas_doc_format",
127                self.client.instance_url(),
128                id
129            );
130
131            let response = self
132                .client
133                .get_json(&url)
134                .await
135                .context("Failed to fetch Confluence page")?;
136
137            if !response.status().is_success() {
138                let status = response.status().as_u16();
139                let body = response.text().await.unwrap_or_default();
140                return Err(AtlassianError::ApiRequestFailed { status, body }.into());
141            }
142
143            let page: ConfluencePageResponse = response
144                .json()
145                .await
146                .context("Failed to parse Confluence page response")?;
147
148            debug!(
149                page_id = page.id,
150                title = page.title,
151                "Fetched Confluence page"
152            );
153
154            // Confluence returns ADF as a JSON string — parse it to a Value.
155            let body_adf = if let Some(body) = &page.body {
156                if let Some(atlas_doc) = &body.atlas_doc_format {
157                    if tracing::enabled!(tracing::Level::TRACE) {
158                        if let Ok(pretty) =
159                            serde_json::from_str::<serde_json::Value>(&atlas_doc.value)
160                                .and_then(|v| serde_json::to_string_pretty(&v))
161                        {
162                            tracing::trace!("Original ADF from Confluence:\n{pretty}");
163                        }
164                    }
165                    Some(
166                        serde_json::from_str(&atlas_doc.value)
167                            .context("Failed to parse ADF from Confluence body")?,
168                    )
169                } else {
170                    None
171                }
172            } else {
173                None
174            };
175
176            // Resolve space key from space ID.
177            let space_key = self.resolve_space_key(&page.space_id).await?;
178
179            Ok(ContentItem {
180                id: page.id,
181                title: page.title,
182                body_adf,
183                metadata: ContentMetadata::Confluence {
184                    space_key,
185                    status: Some(page.status),
186                    version: page.version.map(|v| v.number),
187                    parent_id: page.parent_id,
188                },
189            })
190        })
191    }
192
193    fn update_content<'a>(
194        &'a self,
195        id: &'a str,
196        body_adf: &'a AdfDocument,
197        title: Option<&'a str>,
198    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>> {
199        Box::pin(async move {
200            // Fetch current page to get version number and title.
201            let current = self.get_content(id).await?;
202            let current_version = match &current.metadata {
203                ContentMetadata::Confluence { version, .. } => version.unwrap_or(1),
204                ContentMetadata::Jira { .. } => 1,
205            };
206            let current_title = current.title;
207
208            let adf_json =
209                serde_json::to_string(body_adf).context("Failed to serialize ADF document")?;
210
211            debug!(
212                page_id = id,
213                version = current_version + 1,
214                adf_bytes = adf_json.len(),
215                "Updating Confluence page"
216            );
217            if tracing::enabled!(tracing::Level::TRACE) {
218                let pretty = serde_json::to_string_pretty(body_adf)
219                    .unwrap_or_else(|e| format!("<serialization error: {e}>"));
220                tracing::trace!("ADF body for update:\n{pretty}");
221            }
222
223            let update = ConfluenceUpdateRequest {
224                id: id.to_string(),
225                status: "current".to_string(),
226                title: title.unwrap_or(&current_title).to_string(),
227                body: ConfluenceUpdateBody {
228                    representation: "atlas_doc_format".to_string(),
229                    value: adf_json,
230                },
231                version: ConfluenceUpdateVersion {
232                    number: current_version + 1,
233                    message: None,
234                },
235            };
236
237            let url = format!("{}/wiki/api/v2/pages/{}", self.client.instance_url(), id);
238
239            let response = self
240                .client
241                .put_json(&url, &update)
242                .await
243                .context("Failed to update Confluence page")?;
244
245            if !response.status().is_success() {
246                let status = response.status().as_u16();
247                let body = response.text().await.unwrap_or_default();
248                return Err(AtlassianError::ApiRequestFailed { status, body }.into());
249            }
250
251            Ok(())
252        })
253    }
254
255    fn verify_auth<'a>(&'a self) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>> {
256        // Reuse the JIRA /myself endpoint — same Atlassian Cloud instance.
257        Box::pin(async move {
258            let user = self.client.get_myself().await?;
259            Ok(user.display_name)
260        })
261    }
262
263    fn backend_name(&self) -> &'static str {
264        "confluence"
265    }
266}
267
268impl ConfluenceApi {
269    /// Resolves a space key to a space ID via the Confluence API.
270    pub async fn resolve_space_id(&self, space_key: &str) -> Result<String> {
271        let url = format!(
272            "{}/wiki/api/v2/spaces?keys={}",
273            self.client.instance_url(),
274            space_key
275        );
276
277        let response = self
278            .client
279            .get_json(&url)
280            .await
281            .context("Failed to search Confluence spaces")?;
282
283        if !response.status().is_success() {
284            let status = response.status().as_u16();
285            let body = response.text().await.unwrap_or_default();
286            return Err(AtlassianError::ApiRequestFailed { status, body }.into());
287        }
288
289        let resp: ConfluenceSpacesSearchResponse = response
290            .json()
291            .await
292            .context("Failed to parse Confluence spaces response")?;
293
294        resp.results
295            .first()
296            .map(|s| s.id.clone())
297            .ok_or_else(|| anyhow::anyhow!("Space with key \"{space_key}\" not found"))
298    }
299
300    /// Creates a new Confluence page.
301    pub async fn create_page(
302        &self,
303        space_key: &str,
304        title: &str,
305        body_adf: &AdfDocument,
306        parent_id: Option<&str>,
307    ) -> Result<String> {
308        let space_id = self.resolve_space_id(space_key).await?;
309
310        let adf_json =
311            serde_json::to_string(body_adf).context("Failed to serialize ADF document")?;
312
313        let request = ConfluenceCreateRequest {
314            space_id,
315            title: title.to_string(),
316            body: ConfluenceUpdateBody {
317                representation: "atlas_doc_format".to_string(),
318                value: adf_json,
319            },
320            parent_id: parent_id.map(String::from),
321            status: "current".to_string(),
322        };
323
324        let url = format!("{}/wiki/api/v2/pages", self.client.instance_url());
325
326        let response = self
327            .client
328            .post_json(&url, &request)
329            .await
330            .context("Failed to create Confluence page")?;
331
332        if !response.status().is_success() {
333            let status = response.status().as_u16();
334            let body = response.text().await.unwrap_or_default();
335            return Err(AtlassianError::ApiRequestFailed { status, body }.into());
336        }
337
338        let resp: ConfluenceCreateResponse = response
339            .json()
340            .await
341            .context("Failed to parse Confluence create response")?;
342
343        Ok(resp.id)
344    }
345
346    /// Deletes a Confluence page.
347    pub async fn delete_page(&self, id: &str, purge: bool) -> Result<()> {
348        let mut url = format!("{}/wiki/api/v2/pages/{}", self.client.instance_url(), id);
349        if purge {
350            url.push_str("?purge=true");
351        }
352
353        let response = self.client.delete(&url).await?;
354
355        if !response.status().is_success() {
356            let status = response.status().as_u16();
357            let body = response.text().await.unwrap_or_default();
358            if status == 404 {
359                anyhow::bail!(
360                    "Page {id} not found or insufficient permissions. \
361                     Confluence returns 404 when the API user lacks space-level delete permission. \
362                     Check Space Settings > Permissions."
363                );
364            }
365            return Err(AtlassianError::ApiRequestFailed { status, body }.into());
366        }
367
368        Ok(())
369    }
370
371    /// Resolves a space ID to a space key via the Confluence API.
372    async fn resolve_space_key(&self, space_id: &str) -> Result<String> {
373        let url = format!(
374            "{}/wiki/api/v2/spaces/{}",
375            self.client.instance_url(),
376            space_id
377        );
378
379        let response = self
380            .client
381            .get_json(&url)
382            .await
383            .context("Failed to fetch Confluence space")?;
384
385        if !response.status().is_success() {
386            // Fall back to using the space ID as key if lookup fails.
387            return Ok(space_id.to_string());
388        }
389
390        let space: ConfluenceSpaceResponse = response
391            .json()
392            .await
393            .context("Failed to parse Confluence space response")?;
394
395        Ok(space.key)
396    }
397}
398
399#[cfg(test)]
400#[allow(clippy::unwrap_used, clippy::expect_used)]
401mod tests {
402    use super::*;
403
404    #[test]
405    fn confluence_api_backend_name() {
406        let client =
407            AtlassianClient::new("https://org.atlassian.net", "user@test.com", "token").unwrap();
408        let api = ConfluenceApi::new(client);
409        assert_eq!(api.backend_name(), "confluence");
410    }
411
412    #[test]
413    fn confluence_page_response_deserialization() {
414        let json = r#"{
415            "id": "12345",
416            "title": "Test Page",
417            "status": "current",
418            "spaceId": "98765",
419            "version": {"number": 3},
420            "body": {
421                "atlas_doc_format": {
422                    "value": "{\"version\":1,\"type\":\"doc\",\"content\":[]}"
423                }
424            },
425            "parentId": "11111"
426        }"#;
427        let page: ConfluencePageResponse = serde_json::from_str(json).unwrap();
428        assert_eq!(page.id, "12345");
429        assert_eq!(page.title, "Test Page");
430        assert_eq!(page.status, "current");
431        assert_eq!(page.space_id, "98765");
432        assert_eq!(page.version.unwrap().number, 3);
433        assert_eq!(page.parent_id.as_deref(), Some("11111"));
434
435        let body = page.body.unwrap();
436        let atlas_doc = body.atlas_doc_format.unwrap();
437        let adf: serde_json::Value = serde_json::from_str(&atlas_doc.value).unwrap();
438        assert_eq!(adf["version"], 1);
439        assert_eq!(adf["type"], "doc");
440    }
441
442    #[test]
443    fn confluence_page_response_minimal() {
444        let json = r#"{
445            "id": "99",
446            "title": "Minimal",
447            "status": "draft",
448            "spaceId": "1"
449        }"#;
450        let page: ConfluencePageResponse = serde_json::from_str(json).unwrap();
451        assert_eq!(page.id, "99");
452        assert!(page.version.is_none());
453        assert!(page.body.is_none());
454        assert!(page.parent_id.is_none());
455    }
456
457    #[test]
458    fn confluence_update_request_serialization() {
459        let req = ConfluenceUpdateRequest {
460            id: "12345".to_string(),
461            status: "current".to_string(),
462            title: "Updated Title".to_string(),
463            body: ConfluenceUpdateBody {
464                representation: "atlas_doc_format".to_string(),
465                value: r#"{"version":1,"type":"doc","content":[]}"#.to_string(),
466            },
467            version: ConfluenceUpdateVersion {
468                number: 4,
469                message: None,
470            },
471        };
472
473        let json = serde_json::to_value(&req).unwrap();
474        assert_eq!(json["id"], "12345");
475        assert_eq!(json["status"], "current");
476        assert_eq!(json["title"], "Updated Title");
477        assert_eq!(json["body"]["representation"], "atlas_doc_format");
478        assert_eq!(json["version"]["number"], 4);
479    }
480
481    #[test]
482    fn confluence_update_version_with_message() {
483        let req = ConfluenceUpdateRequest {
484            id: "1".to_string(),
485            status: "current".to_string(),
486            title: "T".to_string(),
487            body: ConfluenceUpdateBody {
488                representation: "atlas_doc_format".to_string(),
489                value: "{}".to_string(),
490            },
491            version: ConfluenceUpdateVersion {
492                number: 2,
493                message: Some("Updated via API".to_string()),
494            },
495        };
496        let json = serde_json::to_value(&req).unwrap();
497        assert_eq!(json["version"]["message"], "Updated via API");
498    }
499
500    #[test]
501    fn confluence_space_response_deserialization() {
502        let json = r#"{"key": "ENG"}"#;
503        let space: ConfluenceSpaceResponse = serde_json::from_str(json).unwrap();
504        assert_eq!(space.key, "ENG");
505    }
506
507    /// Helper to set up a wiremock server with the Confluence page and space endpoints.
508    async fn setup_confluence_mock() -> (wiremock::MockServer, ConfluenceApi) {
509        let server = wiremock::MockServer::start().await;
510
511        let page_json = serde_json::json!({
512            "id": "12345",
513            "title": "Test Page",
514            "status": "current",
515            "spaceId": "98765",
516            "version": {"number": 3},
517            "body": {
518                "atlas_doc_format": {
519                    "value": "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"Hello\"}]}]}"
520                }
521            },
522            "parentId": "11111"
523        });
524
525        wiremock::Mock::given(wiremock::matchers::method("GET"))
526            .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
527            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&page_json))
528            .mount(&server)
529            .await;
530
531        wiremock::Mock::given(wiremock::matchers::method("GET"))
532            .and(wiremock::matchers::path("/wiki/api/v2/spaces/98765"))
533            .respond_with(
534                wiremock::ResponseTemplate::new(200)
535                    .set_body_json(serde_json::json!({"key": "ENG"})),
536            )
537            .mount(&server)
538            .await;
539
540        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
541        let api = ConfluenceApi::new(client);
542
543        (server, api)
544    }
545
546    #[tokio::test]
547    async fn get_content_success() {
548        use crate::atlassian::api::{AtlassianApi, ContentMetadata};
549
550        let (_server, api) = setup_confluence_mock().await;
551        let item = api.get_content("12345").await.unwrap();
552
553        assert_eq!(item.id, "12345");
554        assert_eq!(item.title, "Test Page");
555        assert!(item.body_adf.is_some());
556        match &item.metadata {
557            ContentMetadata::Confluence {
558                space_key,
559                status,
560                version,
561                parent_id,
562            } => {
563                assert_eq!(space_key, "ENG");
564                assert_eq!(status.as_deref(), Some("current"));
565                assert_eq!(*version, Some(3));
566                assert_eq!(parent_id.as_deref(), Some("11111"));
567            }
568            ContentMetadata::Jira { .. } => panic!("Expected Confluence metadata"),
569        }
570    }
571
572    #[tokio::test]
573    async fn get_content_api_error() {
574        use crate::atlassian::api::AtlassianApi;
575
576        let server = wiremock::MockServer::start().await;
577
578        wiremock::Mock::given(wiremock::matchers::method("GET"))
579            .and(wiremock::matchers::path("/wiki/api/v2/pages/99999"))
580            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
581            .mount(&server)
582            .await;
583
584        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
585        let api = ConfluenceApi::new(client);
586        let err = api.get_content("99999").await.unwrap_err();
587        assert!(err.to_string().contains("404"));
588    }
589
590    #[tokio::test]
591    async fn get_content_no_body() {
592        use crate::atlassian::api::AtlassianApi;
593
594        let server = wiremock::MockServer::start().await;
595
596        wiremock::Mock::given(wiremock::matchers::method("GET"))
597            .and(wiremock::matchers::path("/wiki/api/v2/pages/55555"))
598            .respond_with(
599                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
600                    "id": "55555",
601                    "title": "No Body",
602                    "status": "draft",
603                    "spaceId": "11111"
604                })),
605            )
606            .mount(&server)
607            .await;
608
609        wiremock::Mock::given(wiremock::matchers::method("GET"))
610            .and(wiremock::matchers::path("/wiki/api/v2/spaces/11111"))
611            .respond_with(
612                wiremock::ResponseTemplate::new(200)
613                    .set_body_json(serde_json::json!({"key": "DEV"})),
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        let item = api.get_content("55555").await.unwrap();
621        assert!(item.body_adf.is_none());
622    }
623
624    #[tokio::test]
625    async fn update_content_success() {
626        use crate::atlassian::api::AtlassianApi;
627
628        let (server, api) = setup_confluence_mock().await;
629
630        wiremock::Mock::given(wiremock::matchers::method("PUT"))
631            .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
632            .respond_with(wiremock::ResponseTemplate::new(200))
633            .mount(&server)
634            .await;
635
636        let adf = AdfDocument::new();
637        let result = api.update_content("12345", &adf, Some("New Title")).await;
638        assert!(result.is_ok());
639    }
640
641    #[tokio::test]
642    async fn update_content_api_error() {
643        use crate::atlassian::api::AtlassianApi;
644
645        let (server, api) = setup_confluence_mock().await;
646
647        wiremock::Mock::given(wiremock::matchers::method("PUT"))
648            .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
649            .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
650            .mount(&server)
651            .await;
652
653        let adf = AdfDocument::new();
654        let err = api.update_content("12345", &adf, None).await.unwrap_err();
655        assert!(err.to_string().contains("403"));
656    }
657
658    #[tokio::test]
659    async fn verify_auth_success() {
660        use crate::atlassian::api::AtlassianApi;
661
662        let server = wiremock::MockServer::start().await;
663
664        wiremock::Mock::given(wiremock::matchers::method("GET"))
665            .and(wiremock::matchers::path("/rest/api/3/myself"))
666            .respond_with(
667                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
668                    "displayName": "Alice",
669                    "accountId": "abc123"
670                })),
671            )
672            .mount(&server)
673            .await;
674
675        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
676        let api = ConfluenceApi::new(client);
677        let name = api.verify_auth().await.unwrap();
678        assert_eq!(name, "Alice");
679    }
680
681    #[tokio::test]
682    async fn resolve_space_id_success() {
683        let server = wiremock::MockServer::start().await;
684
685        wiremock::Mock::given(wiremock::matchers::method("GET"))
686            .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
687            .respond_with(
688                wiremock::ResponseTemplate::new(200)
689                    .set_body_json(serde_json::json!({"results": [{"id": "98765"}]})),
690            )
691            .mount(&server)
692            .await;
693
694        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
695        let api = ConfluenceApi::new(client);
696        let id = api.resolve_space_id("ENG").await.unwrap();
697        assert_eq!(id, "98765");
698    }
699
700    #[tokio::test]
701    async fn resolve_space_id_not_found() {
702        let server = wiremock::MockServer::start().await;
703
704        wiremock::Mock::given(wiremock::matchers::method("GET"))
705            .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
706            .respond_with(
707                wiremock::ResponseTemplate::new(200)
708                    .set_body_json(serde_json::json!({"results": []})),
709            )
710            .mount(&server)
711            .await;
712
713        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
714        let api = ConfluenceApi::new(client);
715        let err = api.resolve_space_id("NOPE").await.unwrap_err();
716        assert!(err.to_string().contains("not found"));
717    }
718
719    #[tokio::test]
720    async fn resolve_space_id_api_error() {
721        let server = wiremock::MockServer::start().await;
722
723        wiremock::Mock::given(wiremock::matchers::method("GET"))
724            .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
725            .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
726            .mount(&server)
727            .await;
728
729        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
730        let api = ConfluenceApi::new(client);
731        let err = api.resolve_space_id("ENG").await.unwrap_err();
732        assert!(err.to_string().contains("403"));
733    }
734
735    #[tokio::test]
736    async fn create_page_success() {
737        let server = wiremock::MockServer::start().await;
738
739        // Space lookup
740        wiremock::Mock::given(wiremock::matchers::method("GET"))
741            .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
742            .respond_with(
743                wiremock::ResponseTemplate::new(200)
744                    .set_body_json(serde_json::json!({"results": [{"id": "98765"}]})),
745            )
746            .mount(&server)
747            .await;
748
749        // Create page
750        wiremock::Mock::given(wiremock::matchers::method("POST"))
751            .and(wiremock::matchers::path("/wiki/api/v2/pages"))
752            .respond_with(
753                wiremock::ResponseTemplate::new(200)
754                    .set_body_json(serde_json::json!({"id": "54321"})),
755            )
756            .mount(&server)
757            .await;
758
759        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
760        let api = ConfluenceApi::new(client);
761        let adf = AdfDocument::new();
762        let id = api
763            .create_page("ENG", "New Page", &adf, None)
764            .await
765            .unwrap();
766        assert_eq!(id, "54321");
767    }
768
769    #[tokio::test]
770    async fn create_page_with_parent() {
771        let server = wiremock::MockServer::start().await;
772
773        wiremock::Mock::given(wiremock::matchers::method("GET"))
774            .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
775            .respond_with(
776                wiremock::ResponseTemplate::new(200)
777                    .set_body_json(serde_json::json!({"results": [{"id": "98765"}]})),
778            )
779            .mount(&server)
780            .await;
781
782        wiremock::Mock::given(wiremock::matchers::method("POST"))
783            .and(wiremock::matchers::path("/wiki/api/v2/pages"))
784            .respond_with(
785                wiremock::ResponseTemplate::new(200)
786                    .set_body_json(serde_json::json!({"id": "54322"})),
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 adf = AdfDocument::new();
794        let id = api
795            .create_page("ENG", "Child Page", &adf, Some("11111"))
796            .await
797            .unwrap();
798        assert_eq!(id, "54322");
799    }
800
801    #[tokio::test]
802    async fn create_page_api_error() {
803        let server = wiremock::MockServer::start().await;
804
805        wiremock::Mock::given(wiremock::matchers::method("GET"))
806            .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
807            .respond_with(
808                wiremock::ResponseTemplate::new(200)
809                    .set_body_json(serde_json::json!({"results": [{"id": "98765"}]})),
810            )
811            .mount(&server)
812            .await;
813
814        wiremock::Mock::given(wiremock::matchers::method("POST"))
815            .and(wiremock::matchers::path("/wiki/api/v2/pages"))
816            .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Bad Request"))
817            .mount(&server)
818            .await;
819
820        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
821        let api = ConfluenceApi::new(client);
822        let adf = AdfDocument::new();
823        let err = api
824            .create_page("ENG", "Fail", &adf, None)
825            .await
826            .unwrap_err();
827        assert!(err.to_string().contains("400"));
828    }
829
830    #[tokio::test]
831    async fn delete_page_success() {
832        let server = wiremock::MockServer::start().await;
833
834        wiremock::Mock::given(wiremock::matchers::method("DELETE"))
835            .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
836            .respond_with(wiremock::ResponseTemplate::new(204))
837            .expect(1)
838            .mount(&server)
839            .await;
840
841        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
842        let api = ConfluenceApi::new(client);
843        let result = api.delete_page("12345", false).await;
844        assert!(result.is_ok());
845    }
846
847    #[tokio::test]
848    async fn delete_page_with_purge() {
849        let server = wiremock::MockServer::start().await;
850
851        wiremock::Mock::given(wiremock::matchers::method("DELETE"))
852            .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
853            .and(wiremock::matchers::query_param("purge", "true"))
854            .respond_with(wiremock::ResponseTemplate::new(204))
855            .expect(1)
856            .mount(&server)
857            .await;
858
859        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
860        let api = ConfluenceApi::new(client);
861        let result = api.delete_page("12345", true).await;
862        assert!(result.is_ok());
863    }
864
865    #[tokio::test]
866    async fn delete_page_not_found_hints_permissions() {
867        let server = wiremock::MockServer::start().await;
868
869        wiremock::Mock::given(wiremock::matchers::method("DELETE"))
870            .and(wiremock::matchers::path("/wiki/api/v2/pages/99999"))
871            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
872            .expect(1)
873            .mount(&server)
874            .await;
875
876        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
877        let api = ConfluenceApi::new(client);
878        let err = api.delete_page("99999", false).await.unwrap_err();
879        let msg = err.to_string();
880        assert!(msg.contains("not found or insufficient permissions"));
881        assert!(msg.contains("Space Settings"));
882    }
883
884    #[tokio::test]
885    async fn delete_page_forbidden() {
886        let server = wiremock::MockServer::start().await;
887
888        wiremock::Mock::given(wiremock::matchers::method("DELETE"))
889            .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
890            .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
891            .expect(1)
892            .mount(&server)
893            .await;
894
895        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
896        let api = ConfluenceApi::new(client);
897        let err = api.delete_page("12345", false).await.unwrap_err();
898        assert!(err.to_string().contains("403"));
899    }
900
901    #[tokio::test]
902    async fn resolve_space_key_fallback_on_error() {
903        let server = wiremock::MockServer::start().await;
904
905        wiremock::Mock::given(wiremock::matchers::method("GET"))
906            .and(wiremock::matchers::path("/wiki/api/v2/spaces/unknown"))
907            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
908            .mount(&server)
909            .await;
910
911        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
912        let api = ConfluenceApi::new(client);
913        let key = api.resolve_space_key("unknown").await.unwrap();
914        // Falls back to the space ID when lookup fails
915        assert_eq!(key, "unknown");
916    }
917}