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    #[serde(default)]
92    status: Option<String>,
93}
94
95#[derive(Deserialize)]
96struct ConfluenceChildrenLinks {
97    next: Option<String>,
98}
99
100// V2 space-pages response (for `depth=root`).
101#[derive(Deserialize)]
102struct ConfluenceSpacePagesResponse {
103    results: Vec<ConfluenceSpacePageEntry>,
104    #[serde(rename = "_links", default)]
105    links: Option<ConfluenceChildrenLinks>,
106}
107
108#[derive(Deserialize)]
109struct ConfluenceSpacePageEntry {
110    id: String,
111    title: String,
112    #[serde(default)]
113    status: Option<String>,
114    #[serde(rename = "parentId", default)]
115    parent_id: Option<String>,
116}
117
118/// A child page returned from the children API.
119#[derive(Debug, Clone, serde::Serialize)]
120pub struct ChildPage {
121    /// Page ID.
122    pub id: String,
123    /// Page title.
124    pub title: String,
125    /// Page status (e.g. "current", "draft"). Empty if not provided by the API.
126    #[serde(default, skip_serializing_if = "String::is_empty")]
127    pub status: String,
128    /// Parent page ID, if known.
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub parent_id: Option<String>,
131    /// Space key, if known.
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub space_key: Option<String>,
134}
135
136// ── Comment types ─────────────────────────────────────────────────
137
138/// A comment on a Confluence page.
139#[derive(Debug, Clone, Serialize)]
140pub struct ConfluenceComment {
141    /// Comment ID.
142    pub id: String,
143    /// Author display name.
144    pub author: String,
145    /// Comment body as raw ADF JSON.
146    pub body_adf: Option<serde_json::Value>,
147    /// ISO 8601 creation timestamp.
148    pub created: String,
149}
150
151#[derive(Deserialize)]
152struct ConfluenceCommentsResponse {
153    results: Vec<ConfluenceCommentEntry>,
154    #[serde(rename = "_links", default)]
155    links: Option<ConfluenceCommentsLinks>,
156}
157
158#[derive(Deserialize)]
159struct ConfluenceCommentsLinks {
160    next: Option<String>,
161}
162
163#[derive(Deserialize)]
164struct ConfluenceCommentEntry {
165    id: String,
166    #[serde(default)]
167    version: Option<ConfluenceCommentVersion>,
168    #[serde(default)]
169    body: Option<ConfluenceCommentBody>,
170}
171
172#[derive(Deserialize)]
173struct ConfluenceCommentVersion {
174    #[serde(rename = "authorId", default)]
175    author_id: Option<String>,
176    #[serde(rename = "createdAt", default)]
177    created_at: Option<String>,
178}
179
180#[derive(Deserialize)]
181struct ConfluenceCommentBody {
182    atlas_doc_format: Option<ConfluenceAtlasDoc>,
183}
184
185#[derive(Serialize)]
186struct ConfluenceAddCommentRequest {
187    #[serde(rename = "pageId")]
188    page_id: String,
189    body: ConfluenceUpdateBody,
190}
191
192// ── Labels ─────────────────────────────────────────────────────────
193
194#[derive(Deserialize)]
195struct ConfluenceLabelsResponse {
196    results: Vec<ConfluenceLabelEntry>,
197    #[serde(rename = "_links", default)]
198    links: Option<ConfluenceLabelsLinks>,
199}
200
201#[derive(Deserialize)]
202struct ConfluenceLabelEntry {
203    id: String,
204    name: String,
205    prefix: String,
206}
207
208#[derive(Deserialize)]
209struct ConfluenceLabelsLinks {
210    next: Option<String>,
211}
212
213/// A label on a Confluence page.
214#[derive(Debug, Clone, Serialize)]
215pub struct ConfluenceLabel {
216    /// Label ID.
217    pub id: String,
218    /// Label name.
219    pub name: String,
220    /// Label prefix (e.g. "global").
221    pub prefix: String,
222}
223
224#[derive(Serialize)]
225struct ConfluenceAddLabelEntry {
226    prefix: String,
227    name: String,
228}
229
230// ── Create request ─────────────────────────────────────────────────
231
232#[derive(Serialize)]
233struct ConfluenceCreateRequest {
234    #[serde(rename = "spaceId")]
235    space_id: String,
236    title: String,
237    body: ConfluenceUpdateBody,
238    #[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
239    parent_id: Option<String>,
240    status: String,
241}
242
243#[derive(Deserialize)]
244struct ConfluenceCreateResponse {
245    id: String,
246}
247
248// ── Update request ──────────────────────────────────────────────────
249
250#[derive(Serialize)]
251struct ConfluenceUpdateRequest {
252    id: String,
253    status: String,
254    title: String,
255    body: ConfluenceUpdateBody,
256    version: ConfluenceUpdateVersion,
257}
258
259#[derive(Serialize)]
260struct ConfluenceUpdateBody {
261    representation: String,
262    value: String,
263}
264
265#[derive(Serialize)]
266struct ConfluenceUpdateVersion {
267    number: u32,
268    message: Option<String>,
269}
270
271impl AtlassianApi for ConfluenceApi {
272    fn get_content<'a>(
273        &'a self,
274        id: &'a str,
275    ) -> Pin<Box<dyn Future<Output = Result<ContentItem>> + Send + 'a>> {
276        Box::pin(async move {
277            let url = format!(
278                "{}/wiki/api/v2/pages/{}?body-format=atlas_doc_format",
279                self.client.instance_url(),
280                id
281            );
282
283            let response = self
284                .client
285                .get_json(&url)
286                .await
287                .context("Failed to fetch Confluence page")?;
288
289            if !response.status().is_success() {
290                let status = response.status().as_u16();
291                let body = response.text().await.unwrap_or_default();
292                return Err(AtlassianError::ApiRequestFailed { status, body }.into());
293            }
294
295            let page: ConfluencePageResponse = response
296                .json()
297                .await
298                .context("Failed to parse Confluence page response")?;
299
300            debug!(
301                page_id = page.id,
302                title = page.title,
303                "Fetched Confluence page"
304            );
305
306            // Confluence returns ADF as a JSON string — parse it to a Value.
307            let body_adf = if let Some(body) = &page.body {
308                if let Some(atlas_doc) = &body.atlas_doc_format {
309                    if tracing::enabled!(tracing::Level::TRACE) {
310                        if let Ok(pretty) =
311                            serde_json::from_str::<serde_json::Value>(&atlas_doc.value)
312                                .and_then(|v| serde_json::to_string_pretty(&v))
313                        {
314                            tracing::trace!("Original ADF from Confluence:\n{pretty}");
315                        }
316                    }
317                    Some(
318                        serde_json::from_str(&atlas_doc.value)
319                            .context("Failed to parse ADF from Confluence body")?,
320                    )
321                } else {
322                    None
323                }
324            } else {
325                None
326            };
327
328            // Resolve space key from space ID.
329            let space_key = self.resolve_space_key(&page.space_id).await?;
330
331            Ok(ContentItem {
332                id: page.id,
333                title: page.title,
334                body_adf,
335                metadata: ContentMetadata::Confluence {
336                    space_key,
337                    status: Some(page.status),
338                    version: page.version.map(|v| v.number),
339                    parent_id: page.parent_id,
340                },
341            })
342        })
343    }
344
345    fn update_content<'a>(
346        &'a self,
347        id: &'a str,
348        body_adf: &'a AdfDocument,
349        title: Option<&'a str>,
350    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>> {
351        Box::pin(async move {
352            // Fetch current page to get version number and title.
353            let current = self.get_content(id).await?;
354            let current_version = match &current.metadata {
355                ContentMetadata::Confluence { version, .. } => version.unwrap_or(1),
356                ContentMetadata::Jira { .. } => 1,
357            };
358            let current_title = current.title;
359
360            let adf_json =
361                serde_json::to_string(body_adf).context("Failed to serialize ADF document")?;
362
363            debug!(
364                page_id = id,
365                version = current_version + 1,
366                adf_bytes = adf_json.len(),
367                "Updating Confluence page"
368            );
369            if tracing::enabled!(tracing::Level::TRACE) {
370                let pretty = serde_json::to_string_pretty(body_adf)
371                    .unwrap_or_else(|e| format!("<serialization error: {e}>"));
372                tracing::trace!("ADF body for update:\n{pretty}");
373            }
374
375            let update = ConfluenceUpdateRequest {
376                id: id.to_string(),
377                status: "current".to_string(),
378                title: title.unwrap_or(&current_title).to_string(),
379                body: ConfluenceUpdateBody {
380                    representation: "atlas_doc_format".to_string(),
381                    value: adf_json,
382                },
383                version: ConfluenceUpdateVersion {
384                    number: current_version + 1,
385                    message: None,
386                },
387            };
388
389            let url = format!("{}/wiki/api/v2/pages/{}", self.client.instance_url(), id);
390
391            let response = self
392                .client
393                .put_json(&url, &update)
394                .await
395                .context("Failed to update Confluence page")?;
396
397            if !response.status().is_success() {
398                let status = response.status().as_u16();
399                let body = response.text().await.unwrap_or_default();
400                return Err(AtlassianError::ApiRequestFailed { status, body }.into());
401            }
402
403            Ok(())
404        })
405    }
406
407    fn verify_auth<'a>(&'a self) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>> {
408        // Reuse the JIRA /myself endpoint — same Atlassian Cloud instance.
409        Box::pin(async move {
410            let user = self.client.get_myself().await?;
411            Ok(user.display_name)
412        })
413    }
414
415    fn backend_name(&self) -> &'static str {
416        "confluence"
417    }
418}
419
420impl ConfluenceApi {
421    /// Resolves a space key to a space ID via the Confluence API.
422    pub async fn resolve_space_id(&self, space_key: &str) -> Result<String> {
423        let url = format!(
424            "{}/wiki/api/v2/spaces?keys={}",
425            self.client.instance_url(),
426            space_key
427        );
428
429        let response = self
430            .client
431            .get_json(&url)
432            .await
433            .context("Failed to search Confluence spaces")?;
434
435        if !response.status().is_success() {
436            let status = response.status().as_u16();
437            let body = response.text().await.unwrap_or_default();
438            return Err(AtlassianError::ApiRequestFailed { status, body }.into());
439        }
440
441        let resp: ConfluenceSpacesSearchResponse = response
442            .json()
443            .await
444            .context("Failed to parse Confluence spaces response")?;
445
446        resp.results
447            .first()
448            .map(|s| s.id.clone())
449            .ok_or_else(|| anyhow::anyhow!("Space with key \"{space_key}\" not found"))
450    }
451
452    /// Creates a new Confluence page.
453    pub async fn create_page(
454        &self,
455        space_key: &str,
456        title: &str,
457        body_adf: &AdfDocument,
458        parent_id: Option<&str>,
459    ) -> Result<String> {
460        let space_id = self.resolve_space_id(space_key).await?;
461
462        let adf_json =
463            serde_json::to_string(body_adf).context("Failed to serialize ADF document")?;
464
465        let request = ConfluenceCreateRequest {
466            space_id,
467            title: title.to_string(),
468            body: ConfluenceUpdateBody {
469                representation: "atlas_doc_format".to_string(),
470                value: adf_json,
471            },
472            parent_id: parent_id.map(String::from),
473            status: "current".to_string(),
474        };
475
476        let url = format!("{}/wiki/api/v2/pages", self.client.instance_url());
477
478        let response = self
479            .client
480            .post_json(&url, &request)
481            .await
482            .context("Failed to create Confluence page")?;
483
484        if !response.status().is_success() {
485            let status = response.status().as_u16();
486            let body = response.text().await.unwrap_or_default();
487            return Err(AtlassianError::ApiRequestFailed { status, body }.into());
488        }
489
490        let resp: ConfluenceCreateResponse = response
491            .json()
492            .await
493            .context("Failed to parse Confluence create response")?;
494
495        Ok(resp.id)
496    }
497
498    /// Deletes a Confluence page.
499    pub async fn delete_page(&self, id: &str, purge: bool) -> Result<()> {
500        let mut url = format!("{}/wiki/api/v2/pages/{}", self.client.instance_url(), id);
501        if purge {
502            url.push_str("?purge=true");
503        }
504
505        let response = self.client.delete(&url).await?;
506
507        if !response.status().is_success() {
508            let status = response.status().as_u16();
509            let body = response.text().await.unwrap_or_default();
510            if status == 404 {
511                anyhow::bail!(
512                    "Page {id} not found or insufficient permissions. \
513                     Confluence returns 404 when the API user lacks space-level delete permission. \
514                     Check Space Settings > Permissions."
515                );
516            }
517            return Err(AtlassianError::ApiRequestFailed { status, body }.into());
518        }
519
520        Ok(())
521    }
522
523    /// Fetches all child pages of a given page, handling pagination.
524    ///
525    /// Uses the v1 content API (`/wiki/rest/api/content/{id}/child/page`)
526    /// which is more widely supported than the v2 children endpoint.
527    pub async fn get_children(&self, page_id: &str) -> Result<Vec<ChildPage>> {
528        let mut all_children = Vec::new();
529        let mut url = format!(
530            "{}/wiki/rest/api/content/{}/child/page?limit=50",
531            self.client.instance_url(),
532            page_id
533        );
534
535        loop {
536            let response = self
537                .client
538                .get_json(&url)
539                .await
540                .context("Failed to fetch child pages")?;
541
542            if !response.status().is_success() {
543                let status = response.status().as_u16();
544                let body = response.text().await.unwrap_or_default();
545                return Err(AtlassianError::ApiRequestFailed { status, body }.into());
546            }
547
548            let resp: ConfluenceChildrenResponse = response
549                .json()
550                .await
551                .context("Failed to parse children response")?;
552
553            let page_count = resp.results.len();
554            for child in resp.results {
555                all_children.push(ChildPage {
556                    id: child.id,
557                    title: child.title,
558                    status: child.status.unwrap_or_default(),
559                    parent_id: Some(page_id.to_string()),
560                    space_key: None,
561                });
562            }
563
564            match resp.links.and_then(|l| l.next) {
565                Some(next_path) if page_count > 0 => {
566                    url = format!("{}{}", self.client.instance_url(), next_path);
567                }
568                _ => break,
569            }
570        }
571
572        Ok(all_children)
573    }
574
575    /// Fetches top-level pages in a space (pages with no parent), handling pagination.
576    ///
577    /// Uses the v2 API endpoint `/wiki/api/v2/spaces/{space-id}/pages?depth=root`.
578    pub async fn get_space_root_pages(&self, space_id: &str) -> Result<Vec<ChildPage>> {
579        let mut all_pages = Vec::new();
580        let mut url = format!(
581            "{}/wiki/api/v2/spaces/{}/pages?depth=root&limit=50",
582            self.client.instance_url(),
583            space_id
584        );
585
586        loop {
587            let response = self
588                .client
589                .get_json(&url)
590                .await
591                .context("Failed to fetch space root pages")?;
592
593            if !response.status().is_success() {
594                let status = response.status().as_u16();
595                let body = response.text().await.unwrap_or_default();
596                return Err(AtlassianError::ApiRequestFailed { status, body }.into());
597            }
598
599            let resp: ConfluenceSpacePagesResponse = response
600                .json()
601                .await
602                .context("Failed to parse space pages response")?;
603
604            let page_count = resp.results.len();
605            for entry in resp.results {
606                all_pages.push(ChildPage {
607                    id: entry.id,
608                    title: entry.title,
609                    status: entry.status.unwrap_or_default(),
610                    parent_id: entry.parent_id,
611                    space_key: None,
612                });
613            }
614
615            match resp.links.and_then(|l| l.next) {
616                Some(next_path) if page_count > 0 => {
617                    url = format!("{}{}", self.client.instance_url(), next_path);
618                }
619                _ => break,
620            }
621        }
622
623        Ok(all_pages)
624    }
625
626    /// Lists footer comments on a Confluence page, handling pagination.
627    pub async fn get_page_comments(&self, page_id: &str) -> Result<Vec<ConfluenceComment>> {
628        let mut all_comments = Vec::new();
629        let mut url = format!(
630            "{}/wiki/api/v2/pages/{}/footer-comments?body-format=atlas_doc_format",
631            self.client.instance_url(),
632            page_id
633        );
634
635        loop {
636            let response = self
637                .client
638                .get_json(&url)
639                .await
640                .context("Failed to fetch Confluence page comments")?;
641
642            if !response.status().is_success() {
643                let status = response.status().as_u16();
644                let body = response.text().await.unwrap_or_default();
645                return Err(AtlassianError::ApiRequestFailed { status, body }.into());
646            }
647
648            let resp: ConfluenceCommentsResponse = response
649                .json()
650                .await
651                .context("Failed to parse Confluence comments response")?;
652
653            let page_count = resp.results.len();
654            for c in resp.results {
655                let body_adf = c.body.and_then(|b| {
656                    b.atlas_doc_format
657                        .and_then(|a| serde_json::from_str(&a.value).ok())
658                });
659                let author = c
660                    .version
661                    .as_ref()
662                    .and_then(|v| v.author_id.clone())
663                    .unwrap_or_default();
664                let created = c.version.and_then(|v| v.created_at).unwrap_or_default();
665                all_comments.push(ConfluenceComment {
666                    id: c.id,
667                    author,
668                    body_adf,
669                    created,
670                });
671            }
672
673            match resp.links.and_then(|l| l.next) {
674                Some(next_path) if page_count > 0 => {
675                    url = format!("{}{}", self.client.instance_url(), next_path);
676                }
677                _ => break,
678            }
679        }
680
681        Ok(all_comments)
682    }
683
684    /// Adds a footer comment to a Confluence page.
685    pub async fn add_page_comment(&self, page_id: &str, body_adf: &AdfDocument) -> Result<()> {
686        let adf_json =
687            serde_json::to_string(body_adf).context("Failed to serialize ADF document")?;
688
689        let request = ConfluenceAddCommentRequest {
690            page_id: page_id.to_string(),
691            body: ConfluenceUpdateBody {
692                representation: "atlas_doc_format".to_string(),
693                value: adf_json,
694            },
695        };
696
697        let url = format!("{}/wiki/api/v2/footer-comments", self.client.instance_url());
698
699        let response = self
700            .client
701            .post_json(&url, &request)
702            .await
703            .context("Failed to add Confluence page comment")?;
704
705        if !response.status().is_success() {
706            let status = response.status().as_u16();
707            let body = response.text().await.unwrap_or_default();
708            return Err(AtlassianError::ApiRequestFailed { status, body }.into());
709        }
710
711        Ok(())
712    }
713
714    /// Resolves a space ID to a space key via the Confluence API.
715    async fn resolve_space_key(&self, space_id: &str) -> Result<String> {
716        let url = format!(
717            "{}/wiki/api/v2/spaces/{}",
718            self.client.instance_url(),
719            space_id
720        );
721
722        let response = self
723            .client
724            .get_json(&url)
725            .await
726            .context("Failed to fetch Confluence space")?;
727
728        if !response.status().is_success() {
729            // Fall back to using the space ID as key if lookup fails.
730            return Ok(space_id.to_string());
731        }
732
733        let space: ConfluenceSpaceResponse = response
734            .json()
735            .await
736            .context("Failed to parse Confluence space response")?;
737
738        Ok(space.key)
739    }
740
741    /// Fetches all labels on a Confluence page, handling pagination.
742    pub async fn get_labels(&self, page_id: &str) -> Result<Vec<ConfluenceLabel>> {
743        let mut all_labels = Vec::new();
744        let mut url = format!(
745            "{}/wiki/api/v2/pages/{}/labels",
746            self.client.instance_url(),
747            page_id
748        );
749
750        loop {
751            let response = self
752                .client
753                .get_json(&url)
754                .await
755                .context("Failed to fetch page labels")?;
756
757            if !response.status().is_success() {
758                let status = response.status().as_u16();
759                let body = response.text().await.unwrap_or_default();
760                return Err(AtlassianError::ApiRequestFailed { status, body }.into());
761            }
762
763            let resp: ConfluenceLabelsResponse = response
764                .json()
765                .await
766                .context("Failed to parse labels response")?;
767
768            let page_count = resp.results.len();
769            for entry in resp.results {
770                all_labels.push(ConfluenceLabel {
771                    id: entry.id,
772                    name: entry.name,
773                    prefix: entry.prefix,
774                });
775            }
776
777            match resp.links.and_then(|l| l.next) {
778                Some(next_path) if page_count > 0 => {
779                    url = format!("{}{}", self.client.instance_url(), next_path);
780                }
781                _ => break,
782            }
783        }
784
785        Ok(all_labels)
786    }
787
788    /// Adds one or more labels to a Confluence page.
789    pub async fn add_labels(&self, page_id: &str, labels: &[String]) -> Result<()> {
790        let url = format!(
791            "{}/wiki/rest/api/content/{}/label",
792            self.client.instance_url(),
793            page_id
794        );
795
796        let body: Vec<ConfluenceAddLabelEntry> = labels
797            .iter()
798            .map(|name| ConfluenceAddLabelEntry {
799                prefix: "global".to_string(),
800                name: name.clone(),
801            })
802            .collect();
803
804        let response = self
805            .client
806            .post_json(&url, &body)
807            .await
808            .context("Failed to add labels")?;
809
810        if !response.status().is_success() {
811            let status = response.status().as_u16();
812            let body = response.text().await.unwrap_or_default();
813            return Err(AtlassianError::ApiRequestFailed { status, body }.into());
814        }
815
816        Ok(())
817    }
818
819    /// Removes a label from a Confluence page.
820    pub async fn remove_label(&self, page_id: &str, label_name: &str) -> Result<()> {
821        let url = format!(
822            "{}/wiki/rest/api/content/{}/label/{}",
823            self.client.instance_url(),
824            page_id,
825            label_name
826        );
827
828        let response = self.client.delete(&url).await?;
829
830        if !response.status().is_success() {
831            let status = response.status().as_u16();
832            let body = response.text().await.unwrap_or_default();
833            return Err(AtlassianError::ApiRequestFailed { status, body }.into());
834        }
835
836        Ok(())
837    }
838}
839
840#[cfg(test)]
841#[allow(clippy::unwrap_used, clippy::expect_used)]
842mod tests {
843    use super::*;
844
845    #[test]
846    fn confluence_api_backend_name() {
847        let client =
848            AtlassianClient::new("https://org.atlassian.net", "user@test.com", "token").unwrap();
849        let api = ConfluenceApi::new(client);
850        assert_eq!(api.backend_name(), "confluence");
851    }
852
853    #[test]
854    fn confluence_page_response_deserialization() {
855        let json = r#"{
856            "id": "12345",
857            "title": "Test Page",
858            "status": "current",
859            "spaceId": "98765",
860            "version": {"number": 3},
861            "body": {
862                "atlas_doc_format": {
863                    "value": "{\"version\":1,\"type\":\"doc\",\"content\":[]}"
864                }
865            },
866            "parentId": "11111"
867        }"#;
868        let page: ConfluencePageResponse = serde_json::from_str(json).unwrap();
869        assert_eq!(page.id, "12345");
870        assert_eq!(page.title, "Test Page");
871        assert_eq!(page.status, "current");
872        assert_eq!(page.space_id, "98765");
873        assert_eq!(page.version.unwrap().number, 3);
874        assert_eq!(page.parent_id.as_deref(), Some("11111"));
875
876        let body = page.body.unwrap();
877        let atlas_doc = body.atlas_doc_format.unwrap();
878        let adf: serde_json::Value = serde_json::from_str(&atlas_doc.value).unwrap();
879        assert_eq!(adf["version"], 1);
880        assert_eq!(adf["type"], "doc");
881    }
882
883    #[test]
884    fn confluence_page_response_minimal() {
885        let json = r#"{
886            "id": "99",
887            "title": "Minimal",
888            "status": "draft",
889            "spaceId": "1"
890        }"#;
891        let page: ConfluencePageResponse = serde_json::from_str(json).unwrap();
892        assert_eq!(page.id, "99");
893        assert!(page.version.is_none());
894        assert!(page.body.is_none());
895        assert!(page.parent_id.is_none());
896    }
897
898    #[test]
899    fn confluence_update_request_serialization() {
900        let req = ConfluenceUpdateRequest {
901            id: "12345".to_string(),
902            status: "current".to_string(),
903            title: "Updated Title".to_string(),
904            body: ConfluenceUpdateBody {
905                representation: "atlas_doc_format".to_string(),
906                value: r#"{"version":1,"type":"doc","content":[]}"#.to_string(),
907            },
908            version: ConfluenceUpdateVersion {
909                number: 4,
910                message: None,
911            },
912        };
913
914        let json = serde_json::to_value(&req).unwrap();
915        assert_eq!(json["id"], "12345");
916        assert_eq!(json["status"], "current");
917        assert_eq!(json["title"], "Updated Title");
918        assert_eq!(json["body"]["representation"], "atlas_doc_format");
919        assert_eq!(json["version"]["number"], 4);
920    }
921
922    #[test]
923    fn confluence_update_version_with_message() {
924        let req = ConfluenceUpdateRequest {
925            id: "1".to_string(),
926            status: "current".to_string(),
927            title: "T".to_string(),
928            body: ConfluenceUpdateBody {
929                representation: "atlas_doc_format".to_string(),
930                value: "{}".to_string(),
931            },
932            version: ConfluenceUpdateVersion {
933                number: 2,
934                message: Some("Updated via API".to_string()),
935            },
936        };
937        let json = serde_json::to_value(&req).unwrap();
938        assert_eq!(json["version"]["message"], "Updated via API");
939    }
940
941    #[test]
942    fn confluence_space_response_deserialization() {
943        let json = r#"{"key": "ENG"}"#;
944        let space: ConfluenceSpaceResponse = serde_json::from_str(json).unwrap();
945        assert_eq!(space.key, "ENG");
946    }
947
948    /// Helper to set up a wiremock server with the Confluence page and space endpoints.
949    async fn setup_confluence_mock() -> (wiremock::MockServer, ConfluenceApi) {
950        let server = wiremock::MockServer::start().await;
951
952        let page_json = serde_json::json!({
953            "id": "12345",
954            "title": "Test Page",
955            "status": "current",
956            "spaceId": "98765",
957            "version": {"number": 3},
958            "body": {
959                "atlas_doc_format": {
960                    "value": "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"Hello\"}]}]}"
961                }
962            },
963            "parentId": "11111"
964        });
965
966        wiremock::Mock::given(wiremock::matchers::method("GET"))
967            .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
968            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&page_json))
969            .mount(&server)
970            .await;
971
972        wiremock::Mock::given(wiremock::matchers::method("GET"))
973            .and(wiremock::matchers::path("/wiki/api/v2/spaces/98765"))
974            .respond_with(
975                wiremock::ResponseTemplate::new(200)
976                    .set_body_json(serde_json::json!({"key": "ENG"})),
977            )
978            .mount(&server)
979            .await;
980
981        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
982        let api = ConfluenceApi::new(client);
983
984        (server, api)
985    }
986
987    #[tokio::test]
988    async fn get_content_success() {
989        use crate::atlassian::api::{AtlassianApi, ContentMetadata};
990
991        let (_server, api) = setup_confluence_mock().await;
992        let item = api.get_content("12345").await.unwrap();
993
994        assert_eq!(item.id, "12345");
995        assert_eq!(item.title, "Test Page");
996        assert!(item.body_adf.is_some());
997        match &item.metadata {
998            ContentMetadata::Confluence {
999                space_key,
1000                status,
1001                version,
1002                parent_id,
1003            } => {
1004                assert_eq!(space_key, "ENG");
1005                assert_eq!(status.as_deref(), Some("current"));
1006                assert_eq!(*version, Some(3));
1007                assert_eq!(parent_id.as_deref(), Some("11111"));
1008            }
1009            ContentMetadata::Jira { .. } => panic!("Expected Confluence metadata"),
1010        }
1011    }
1012
1013    #[tokio::test]
1014    async fn get_content_api_error() {
1015        use crate::atlassian::api::AtlassianApi;
1016
1017        let server = wiremock::MockServer::start().await;
1018
1019        wiremock::Mock::given(wiremock::matchers::method("GET"))
1020            .and(wiremock::matchers::path("/wiki/api/v2/pages/99999"))
1021            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
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 err = api.get_content("99999").await.unwrap_err();
1028        assert!(err.to_string().contains("404"));
1029    }
1030
1031    #[tokio::test]
1032    async fn get_content_no_body() {
1033        use crate::atlassian::api::AtlassianApi;
1034
1035        let server = wiremock::MockServer::start().await;
1036
1037        wiremock::Mock::given(wiremock::matchers::method("GET"))
1038            .and(wiremock::matchers::path("/wiki/api/v2/pages/55555"))
1039            .respond_with(
1040                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1041                    "id": "55555",
1042                    "title": "No Body",
1043                    "status": "draft",
1044                    "spaceId": "11111"
1045                })),
1046            )
1047            .mount(&server)
1048            .await;
1049
1050        wiremock::Mock::given(wiremock::matchers::method("GET"))
1051            .and(wiremock::matchers::path("/wiki/api/v2/spaces/11111"))
1052            .respond_with(
1053                wiremock::ResponseTemplate::new(200)
1054                    .set_body_json(serde_json::json!({"key": "DEV"})),
1055            )
1056            .mount(&server)
1057            .await;
1058
1059        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1060        let api = ConfluenceApi::new(client);
1061        let item = api.get_content("55555").await.unwrap();
1062        assert!(item.body_adf.is_none());
1063    }
1064
1065    #[tokio::test]
1066    async fn update_content_success() {
1067        use crate::atlassian::api::AtlassianApi;
1068
1069        let (server, api) = setup_confluence_mock().await;
1070
1071        wiremock::Mock::given(wiremock::matchers::method("PUT"))
1072            .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
1073            .respond_with(wiremock::ResponseTemplate::new(200))
1074            .mount(&server)
1075            .await;
1076
1077        let adf = AdfDocument::new();
1078        let result = api.update_content("12345", &adf, Some("New Title")).await;
1079        assert!(result.is_ok());
1080    }
1081
1082    #[tokio::test]
1083    async fn update_content_api_error() {
1084        use crate::atlassian::api::AtlassianApi;
1085
1086        let (server, api) = setup_confluence_mock().await;
1087
1088        wiremock::Mock::given(wiremock::matchers::method("PUT"))
1089            .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
1090            .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
1091            .mount(&server)
1092            .await;
1093
1094        let adf = AdfDocument::new();
1095        let err = api.update_content("12345", &adf, None).await.unwrap_err();
1096        assert!(err.to_string().contains("403"));
1097    }
1098
1099    #[tokio::test]
1100    async fn verify_auth_success() {
1101        use crate::atlassian::api::AtlassianApi;
1102
1103        let server = wiremock::MockServer::start().await;
1104
1105        wiremock::Mock::given(wiremock::matchers::method("GET"))
1106            .and(wiremock::matchers::path("/rest/api/3/myself"))
1107            .respond_with(
1108                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1109                    "displayName": "Alice",
1110                    "accountId": "abc123"
1111                })),
1112            )
1113            .mount(&server)
1114            .await;
1115
1116        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1117        let api = ConfluenceApi::new(client);
1118        let name = api.verify_auth().await.unwrap();
1119        assert_eq!(name, "Alice");
1120    }
1121
1122    #[tokio::test]
1123    async fn resolve_space_id_success() {
1124        let server = wiremock::MockServer::start().await;
1125
1126        wiremock::Mock::given(wiremock::matchers::method("GET"))
1127            .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
1128            .respond_with(
1129                wiremock::ResponseTemplate::new(200)
1130                    .set_body_json(serde_json::json!({"results": [{"id": "98765"}]})),
1131            )
1132            .mount(&server)
1133            .await;
1134
1135        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1136        let api = ConfluenceApi::new(client);
1137        let id = api.resolve_space_id("ENG").await.unwrap();
1138        assert_eq!(id, "98765");
1139    }
1140
1141    #[tokio::test]
1142    async fn resolve_space_id_not_found() {
1143        let server = wiremock::MockServer::start().await;
1144
1145        wiremock::Mock::given(wiremock::matchers::method("GET"))
1146            .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
1147            .respond_with(
1148                wiremock::ResponseTemplate::new(200)
1149                    .set_body_json(serde_json::json!({"results": []})),
1150            )
1151            .mount(&server)
1152            .await;
1153
1154        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1155        let api = ConfluenceApi::new(client);
1156        let err = api.resolve_space_id("NOPE").await.unwrap_err();
1157        assert!(err.to_string().contains("not found"));
1158    }
1159
1160    #[tokio::test]
1161    async fn resolve_space_id_api_error() {
1162        let server = wiremock::MockServer::start().await;
1163
1164        wiremock::Mock::given(wiremock::matchers::method("GET"))
1165            .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
1166            .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
1167            .mount(&server)
1168            .await;
1169
1170        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1171        let api = ConfluenceApi::new(client);
1172        let err = api.resolve_space_id("ENG").await.unwrap_err();
1173        assert!(err.to_string().contains("403"));
1174    }
1175
1176    #[tokio::test]
1177    async fn create_page_success() {
1178        let server = wiremock::MockServer::start().await;
1179
1180        // Space lookup
1181        wiremock::Mock::given(wiremock::matchers::method("GET"))
1182            .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
1183            .respond_with(
1184                wiremock::ResponseTemplate::new(200)
1185                    .set_body_json(serde_json::json!({"results": [{"id": "98765"}]})),
1186            )
1187            .mount(&server)
1188            .await;
1189
1190        // Create page
1191        wiremock::Mock::given(wiremock::matchers::method("POST"))
1192            .and(wiremock::matchers::path("/wiki/api/v2/pages"))
1193            .respond_with(
1194                wiremock::ResponseTemplate::new(200)
1195                    .set_body_json(serde_json::json!({"id": "54321"})),
1196            )
1197            .mount(&server)
1198            .await;
1199
1200        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1201        let api = ConfluenceApi::new(client);
1202        let adf = AdfDocument::new();
1203        let id = api
1204            .create_page("ENG", "New Page", &adf, None)
1205            .await
1206            .unwrap();
1207        assert_eq!(id, "54321");
1208    }
1209
1210    #[tokio::test]
1211    async fn create_page_with_parent() {
1212        let server = wiremock::MockServer::start().await;
1213
1214        wiremock::Mock::given(wiremock::matchers::method("GET"))
1215            .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
1216            .respond_with(
1217                wiremock::ResponseTemplate::new(200)
1218                    .set_body_json(serde_json::json!({"results": [{"id": "98765"}]})),
1219            )
1220            .mount(&server)
1221            .await;
1222
1223        wiremock::Mock::given(wiremock::matchers::method("POST"))
1224            .and(wiremock::matchers::path("/wiki/api/v2/pages"))
1225            .respond_with(
1226                wiremock::ResponseTemplate::new(200)
1227                    .set_body_json(serde_json::json!({"id": "54322"})),
1228            )
1229            .mount(&server)
1230            .await;
1231
1232        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1233        let api = ConfluenceApi::new(client);
1234        let adf = AdfDocument::new();
1235        let id = api
1236            .create_page("ENG", "Child Page", &adf, Some("11111"))
1237            .await
1238            .unwrap();
1239        assert_eq!(id, "54322");
1240    }
1241
1242    #[tokio::test]
1243    async fn create_page_api_error() {
1244        let server = wiremock::MockServer::start().await;
1245
1246        wiremock::Mock::given(wiremock::matchers::method("GET"))
1247            .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
1248            .respond_with(
1249                wiremock::ResponseTemplate::new(200)
1250                    .set_body_json(serde_json::json!({"results": [{"id": "98765"}]})),
1251            )
1252            .mount(&server)
1253            .await;
1254
1255        wiremock::Mock::given(wiremock::matchers::method("POST"))
1256            .and(wiremock::matchers::path("/wiki/api/v2/pages"))
1257            .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Bad Request"))
1258            .mount(&server)
1259            .await;
1260
1261        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1262        let api = ConfluenceApi::new(client);
1263        let adf = AdfDocument::new();
1264        let err = api
1265            .create_page("ENG", "Fail", &adf, None)
1266            .await
1267            .unwrap_err();
1268        assert!(err.to_string().contains("400"));
1269    }
1270
1271    #[tokio::test]
1272    async fn delete_page_success() {
1273        let server = wiremock::MockServer::start().await;
1274
1275        wiremock::Mock::given(wiremock::matchers::method("DELETE"))
1276            .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
1277            .respond_with(wiremock::ResponseTemplate::new(204))
1278            .expect(1)
1279            .mount(&server)
1280            .await;
1281
1282        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1283        let api = ConfluenceApi::new(client);
1284        let result = api.delete_page("12345", false).await;
1285        assert!(result.is_ok());
1286    }
1287
1288    #[tokio::test]
1289    async fn delete_page_with_purge() {
1290        let server = wiremock::MockServer::start().await;
1291
1292        wiremock::Mock::given(wiremock::matchers::method("DELETE"))
1293            .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
1294            .and(wiremock::matchers::query_param("purge", "true"))
1295            .respond_with(wiremock::ResponseTemplate::new(204))
1296            .expect(1)
1297            .mount(&server)
1298            .await;
1299
1300        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1301        let api = ConfluenceApi::new(client);
1302        let result = api.delete_page("12345", true).await;
1303        assert!(result.is_ok());
1304    }
1305
1306    #[tokio::test]
1307    async fn delete_page_not_found_hints_permissions() {
1308        let server = wiremock::MockServer::start().await;
1309
1310        wiremock::Mock::given(wiremock::matchers::method("DELETE"))
1311            .and(wiremock::matchers::path("/wiki/api/v2/pages/99999"))
1312            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
1313            .expect(1)
1314            .mount(&server)
1315            .await;
1316
1317        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1318        let api = ConfluenceApi::new(client);
1319        let err = api.delete_page("99999", false).await.unwrap_err();
1320        let msg = err.to_string();
1321        assert!(msg.contains("not found or insufficient permissions"));
1322        assert!(msg.contains("Space Settings"));
1323    }
1324
1325    #[tokio::test]
1326    async fn delete_page_forbidden() {
1327        let server = wiremock::MockServer::start().await;
1328
1329        wiremock::Mock::given(wiremock::matchers::method("DELETE"))
1330            .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
1331            .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
1332            .expect(1)
1333            .mount(&server)
1334            .await;
1335
1336        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1337        let api = ConfluenceApi::new(client);
1338        let err = api.delete_page("12345", false).await.unwrap_err();
1339        assert!(err.to_string().contains("403"));
1340    }
1341
1342    #[tokio::test]
1343    async fn get_children_success() {
1344        let server = wiremock::MockServer::start().await;
1345
1346        wiremock::Mock::given(wiremock::matchers::method("GET"))
1347            .and(wiremock::matchers::path(
1348                "/wiki/rest/api/content/12345/child/page",
1349            ))
1350            .respond_with(
1351                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1352                    "results": [
1353                        {"id": "111", "title": "Child One"},
1354                        {"id": "222", "title": "Child Two"}
1355                    ]
1356                })),
1357            )
1358            .expect(1)
1359            .mount(&server)
1360            .await;
1361
1362        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1363        let api = ConfluenceApi::new(client);
1364        let children = api.get_children("12345").await.unwrap();
1365
1366        assert_eq!(children.len(), 2);
1367        assert_eq!(children[0].id, "111");
1368        assert_eq!(children[0].title, "Child One");
1369        assert_eq!(children[1].id, "222");
1370    }
1371
1372    #[tokio::test]
1373    async fn get_children_empty() {
1374        let server = wiremock::MockServer::start().await;
1375
1376        wiremock::Mock::given(wiremock::matchers::method("GET"))
1377            .and(wiremock::matchers::path(
1378                "/wiki/rest/api/content/12345/child/page",
1379            ))
1380            .respond_with(
1381                wiremock::ResponseTemplate::new(200)
1382                    .set_body_json(serde_json::json!({"results": []})),
1383            )
1384            .expect(1)
1385            .mount(&server)
1386            .await;
1387
1388        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1389        let api = ConfluenceApi::new(client);
1390        let children = api.get_children("12345").await.unwrap();
1391        assert!(children.is_empty());
1392    }
1393
1394    #[tokio::test]
1395    async fn get_children_pagination() {
1396        let server = wiremock::MockServer::start().await;
1397
1398        wiremock::Mock::given(wiremock::matchers::method("GET"))
1399            .and(wiremock::matchers::path(
1400                "/wiki/rest/api/content/12345/child/page",
1401            ))
1402            .and(wiremock::matchers::query_param_is_missing("start"))
1403            .respond_with(
1404                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1405                    "results": [{"id": "111", "title": "First", "status": "current"}],
1406                    "_links": {
1407                        "next": "/wiki/rest/api/content/12345/child/page?limit=50&start=50"
1408                    }
1409                })),
1410            )
1411            .expect(1)
1412            .mount(&server)
1413            .await;
1414
1415        wiremock::Mock::given(wiremock::matchers::method("GET"))
1416            .and(wiremock::matchers::path(
1417                "/wiki/rest/api/content/12345/child/page",
1418            ))
1419            .and(wiremock::matchers::query_param("start", "50"))
1420            .respond_with(
1421                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1422                    "results": [{"id": "222", "title": "Second", "status": "current"}]
1423                })),
1424            )
1425            .expect(1)
1426            .mount(&server)
1427            .await;
1428
1429        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1430        let api = ConfluenceApi::new(client);
1431        let children = api.get_children("12345").await.unwrap();
1432        assert_eq!(children.len(), 2);
1433        assert_eq!(children[0].status, "current");
1434        assert_eq!(children[0].parent_id.as_deref(), Some("12345"));
1435    }
1436
1437    #[tokio::test]
1438    async fn get_space_root_pages_success() {
1439        let server = wiremock::MockServer::start().await;
1440
1441        wiremock::Mock::given(wiremock::matchers::method("GET"))
1442            .and(wiremock::matchers::path("/wiki/api/v2/spaces/98765/pages"))
1443            .and(wiremock::matchers::query_param("depth", "root"))
1444            .respond_with(
1445                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1446                    "results": [
1447                        {"id": "111", "title": "Top One", "status": "current"},
1448                        {"id": "222", "title": "Top Two", "status": "draft", "parentId": null}
1449                    ]
1450                })),
1451            )
1452            .expect(1)
1453            .mount(&server)
1454            .await;
1455
1456        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1457        let api = ConfluenceApi::new(client);
1458        let pages = api.get_space_root_pages("98765").await.unwrap();
1459        assert_eq!(pages.len(), 2);
1460        assert_eq!(pages[0].id, "111");
1461        assert_eq!(pages[0].status, "current");
1462        assert_eq!(pages[1].status, "draft");
1463    }
1464
1465    #[tokio::test]
1466    async fn get_space_root_pages_empty() {
1467        let server = wiremock::MockServer::start().await;
1468
1469        wiremock::Mock::given(wiremock::matchers::method("GET"))
1470            .and(wiremock::matchers::path("/wiki/api/v2/spaces/98765/pages"))
1471            .respond_with(
1472                wiremock::ResponseTemplate::new(200)
1473                    .set_body_json(serde_json::json!({"results": []})),
1474            )
1475            .expect(1)
1476            .mount(&server)
1477            .await;
1478
1479        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1480        let api = ConfluenceApi::new(client);
1481        let pages = api.get_space_root_pages("98765").await.unwrap();
1482        assert!(pages.is_empty());
1483    }
1484
1485    #[tokio::test]
1486    async fn get_space_root_pages_api_error() {
1487        let server = wiremock::MockServer::start().await;
1488
1489        wiremock::Mock::given(wiremock::matchers::method("GET"))
1490            .and(wiremock::matchers::path("/wiki/api/v2/spaces/99999/pages"))
1491            .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
1492            .expect(1)
1493            .mount(&server)
1494            .await;
1495
1496        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1497        let api = ConfluenceApi::new(client);
1498        let err = api.get_space_root_pages("99999").await.unwrap_err();
1499        assert!(err.to_string().contains("403"));
1500    }
1501
1502    #[tokio::test]
1503    async fn get_space_root_pages_pagination() {
1504        let server = wiremock::MockServer::start().await;
1505
1506        wiremock::Mock::given(wiremock::matchers::method("GET"))
1507            .and(wiremock::matchers::path("/wiki/api/v2/spaces/98765/pages"))
1508            .and(wiremock::matchers::query_param_is_missing("cursor"))
1509            .respond_with(
1510                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1511                    "results": [{"id": "111", "title": "A", "status": "current"}],
1512                    "_links": {
1513                        "next": "/wiki/api/v2/spaces/98765/pages?depth=root&cursor=page2"
1514                    }
1515                })),
1516            )
1517            .expect(1)
1518            .mount(&server)
1519            .await;
1520
1521        wiremock::Mock::given(wiremock::matchers::method("GET"))
1522            .and(wiremock::matchers::path("/wiki/api/v2/spaces/98765/pages"))
1523            .and(wiremock::matchers::query_param("cursor", "page2"))
1524            .respond_with(
1525                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1526                    "results": [{"id": "222", "title": "B", "status": "current"}]
1527                })),
1528            )
1529            .expect(1)
1530            .mount(&server)
1531            .await;
1532
1533        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1534        let api = ConfluenceApi::new(client);
1535        let pages = api.get_space_root_pages("98765").await.unwrap();
1536        assert_eq!(pages.len(), 2);
1537        assert_eq!(pages[0].id, "111");
1538        assert_eq!(pages[1].id, "222");
1539    }
1540
1541    #[tokio::test]
1542    async fn get_children_api_error() {
1543        let server = wiremock::MockServer::start().await;
1544
1545        wiremock::Mock::given(wiremock::matchers::method("GET"))
1546            .and(wiremock::matchers::path(
1547                "/wiki/rest/api/content/99999/child/page",
1548            ))
1549            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
1550            .expect(1)
1551            .mount(&server)
1552            .await;
1553
1554        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1555        let api = ConfluenceApi::new(client);
1556        let err = api.get_children("99999").await.unwrap_err();
1557        assert!(err.to_string().contains("404"));
1558    }
1559
1560    #[tokio::test]
1561    async fn resolve_space_key_fallback_on_error() {
1562        let server = wiremock::MockServer::start().await;
1563
1564        wiremock::Mock::given(wiremock::matchers::method("GET"))
1565            .and(wiremock::matchers::path("/wiki/api/v2/spaces/unknown"))
1566            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
1567            .mount(&server)
1568            .await;
1569
1570        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1571        let api = ConfluenceApi::new(client);
1572        let key = api.resolve_space_key("unknown").await.unwrap();
1573        // Falls back to the space ID when lookup fails
1574        assert_eq!(key, "unknown");
1575    }
1576
1577    // ── get_page_comments ─────────────────────────────────────────
1578
1579    #[tokio::test]
1580    async fn get_page_comments_success() {
1581        let server = wiremock::MockServer::start().await;
1582
1583        wiremock::Mock::given(wiremock::matchers::method("GET"))
1584            .and(wiremock::matchers::path(
1585                "/wiki/api/v2/pages/12345/footer-comments",
1586            ))
1587            .respond_with(
1588                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1589                    "results": [
1590                        {
1591                            "id": "100",
1592                            "version": {
1593                                "authorId": "user-abc",
1594                                "createdAt": "2026-04-01T10:00:00.000Z"
1595                            },
1596                            "body": {
1597                                "atlas_doc_format": {
1598                                    "value": "{\"version\":1,\"type\":\"doc\",\"content\":[]}"
1599                                }
1600                            }
1601                        },
1602                        {
1603                            "id": "101",
1604                            "version": {
1605                                "authorId": "user-def",
1606                                "createdAt": "2026-04-02T14:00:00.000Z"
1607                            }
1608                        }
1609                    ]
1610                })),
1611            )
1612            .expect(1)
1613            .mount(&server)
1614            .await;
1615
1616        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1617        let api = ConfluenceApi::new(client);
1618        let comments = api.get_page_comments("12345").await.unwrap();
1619
1620        assert_eq!(comments.len(), 2);
1621        assert_eq!(comments[0].id, "100");
1622        assert_eq!(comments[0].author, "user-abc");
1623        assert!(comments[0].body_adf.is_some());
1624        assert_eq!(comments[1].id, "101");
1625        assert!(comments[1].body_adf.is_none());
1626    }
1627
1628    #[tokio::test]
1629    async fn get_page_comments_malformed_adf_body() {
1630        let server = wiremock::MockServer::start().await;
1631
1632        wiremock::Mock::given(wiremock::matchers::method("GET"))
1633            .and(wiremock::matchers::path(
1634                "/wiki/api/v2/pages/12345/footer-comments",
1635            ))
1636            .respond_with(
1637                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1638                    "results": [
1639                        {
1640                            "id": "100",
1641                            "version": {
1642                                "authorId": "user-abc",
1643                                "createdAt": "2026-04-01T10:00:00.000Z"
1644                            },
1645                            "body": {
1646                                "atlas_doc_format": {
1647                                    "value": "{ invalid json }"
1648                                }
1649                            }
1650                        }
1651                    ]
1652                })),
1653            )
1654            .expect(1)
1655            .mount(&server)
1656            .await;
1657
1658        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1659        let api = ConfluenceApi::new(client);
1660        let comments = api.get_page_comments("12345").await.unwrap();
1661
1662        assert_eq!(comments.len(), 1);
1663        assert_eq!(comments[0].id, "100");
1664        // Malformed ADF silently becomes None
1665        assert!(comments[0].body_adf.is_none());
1666    }
1667
1668    #[tokio::test]
1669    async fn get_page_comments_missing_version() {
1670        let server = wiremock::MockServer::start().await;
1671
1672        wiremock::Mock::given(wiremock::matchers::method("GET"))
1673            .and(wiremock::matchers::path(
1674                "/wiki/api/v2/pages/12345/footer-comments",
1675            ))
1676            .respond_with(
1677                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1678                    "results": [
1679                        {
1680                            "id": "100"
1681                        }
1682                    ]
1683                })),
1684            )
1685            .expect(1)
1686            .mount(&server)
1687            .await;
1688
1689        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1690        let api = ConfluenceApi::new(client);
1691        let comments = api.get_page_comments("12345").await.unwrap();
1692
1693        assert_eq!(comments.len(), 1);
1694        assert_eq!(comments[0].author, "");
1695        assert_eq!(comments[0].created, "");
1696        assert!(comments[0].body_adf.is_none());
1697    }
1698
1699    #[tokio::test]
1700    async fn get_page_comments_empty() {
1701        let server = wiremock::MockServer::start().await;
1702
1703        wiremock::Mock::given(wiremock::matchers::method("GET"))
1704            .and(wiremock::matchers::path(
1705                "/wiki/api/v2/pages/12345/footer-comments",
1706            ))
1707            .respond_with(
1708                wiremock::ResponseTemplate::new(200)
1709                    .set_body_json(serde_json::json!({"results": []})),
1710            )
1711            .expect(1)
1712            .mount(&server)
1713            .await;
1714
1715        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1716        let api = ConfluenceApi::new(client);
1717        let comments = api.get_page_comments("12345").await.unwrap();
1718        assert!(comments.is_empty());
1719    }
1720
1721    #[tokio::test]
1722    async fn get_page_comments_api_error() {
1723        let server = wiremock::MockServer::start().await;
1724
1725        wiremock::Mock::given(wiremock::matchers::method("GET"))
1726            .and(wiremock::matchers::path(
1727                "/wiki/api/v2/pages/99999/footer-comments",
1728            ))
1729            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
1730            .expect(1)
1731            .mount(&server)
1732            .await;
1733
1734        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1735        let api = ConfluenceApi::new(client);
1736        let err = api.get_page_comments("99999").await.unwrap_err();
1737        assert!(err.to_string().contains("404"));
1738    }
1739
1740    #[tokio::test]
1741    async fn get_page_comments_with_pagination() {
1742        let server = wiremock::MockServer::start().await;
1743
1744        // First page returns one comment with a next link.
1745        wiremock::Mock::given(wiremock::matchers::method("GET"))
1746            .and(wiremock::matchers::path(
1747                "/wiki/api/v2/pages/12345/footer-comments",
1748            ))
1749            .and(wiremock::matchers::query_param_is_missing("cursor"))
1750            .respond_with(
1751                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1752                    "results": [
1753                        {
1754                            "id": "100",
1755                            "version": {
1756                                "authorId": "user-abc",
1757                                "createdAt": "2026-04-01T10:00:00.000Z"
1758                            },
1759                            "body": {
1760                                "atlas_doc_format": {
1761                                    "value": "{\"version\":1,\"type\":\"doc\",\"content\":[]}"
1762                                }
1763                            }
1764                        }
1765                    ],
1766                    "_links": {
1767                        "next": "/wiki/api/v2/pages/12345/footer-comments?body-format=atlas_doc_format&cursor=page2"
1768                    }
1769                })),
1770            )
1771            .expect(1)
1772            .mount(&server)
1773            .await;
1774
1775        // Second page returns another comment with no next link.
1776        wiremock::Mock::given(wiremock::matchers::method("GET"))
1777            .and(wiremock::matchers::path(
1778                "/wiki/api/v2/pages/12345/footer-comments",
1779            ))
1780            .and(wiremock::matchers::query_param("cursor", "page2"))
1781            .respond_with(
1782                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1783                    "results": [
1784                        {
1785                            "id": "101",
1786                            "version": {
1787                                "authorId": "user-def",
1788                                "createdAt": "2026-04-02T14:00:00.000Z"
1789                            }
1790                        }
1791                    ]
1792                })),
1793            )
1794            .expect(1)
1795            .mount(&server)
1796            .await;
1797
1798        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1799        let api = ConfluenceApi::new(client);
1800        let comments = api.get_page_comments("12345").await.unwrap();
1801
1802        assert_eq!(comments.len(), 2);
1803        assert_eq!(comments[0].id, "100");
1804        assert_eq!(comments[0].author, "user-abc");
1805        assert!(comments[0].body_adf.is_some());
1806        assert_eq!(comments[1].id, "101");
1807        assert_eq!(comments[1].author, "user-def");
1808    }
1809
1810    #[tokio::test]
1811    async fn get_page_comments_pagination_stops_on_empty_page() {
1812        let server = wiremock::MockServer::start().await;
1813
1814        // Response advertises a next link but returns no results; loop must stop
1815        // to avoid infinite pagination.
1816        wiremock::Mock::given(wiremock::matchers::method("GET"))
1817            .and(wiremock::matchers::path(
1818                "/wiki/api/v2/pages/12345/footer-comments",
1819            ))
1820            .respond_with(
1821                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1822                    "results": [],
1823                    "_links": {
1824                        "next": "/wiki/api/v2/pages/12345/footer-comments?cursor=loop"
1825                    }
1826                })),
1827            )
1828            .expect(1)
1829            .mount(&server)
1830            .await;
1831
1832        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1833        let api = ConfluenceApi::new(client);
1834        let comments = api.get_page_comments("12345").await.unwrap();
1835        assert!(comments.is_empty());
1836    }
1837
1838    // ── add_page_comment ──────────────────────────────────────────
1839
1840    #[tokio::test]
1841    async fn add_page_comment_success() {
1842        let server = wiremock::MockServer::start().await;
1843
1844        wiremock::Mock::given(wiremock::matchers::method("POST"))
1845            .and(wiremock::matchers::path("/wiki/api/v2/footer-comments"))
1846            .respond_with(
1847                wiremock::ResponseTemplate::new(200)
1848                    .set_body_json(serde_json::json!({"id": "200"})),
1849            )
1850            .expect(1)
1851            .mount(&server)
1852            .await;
1853
1854        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1855        let api = ConfluenceApi::new(client);
1856        let adf = AdfDocument::new();
1857        let result = api.add_page_comment("12345", &adf).await;
1858        assert!(result.is_ok());
1859    }
1860
1861    #[tokio::test]
1862    async fn add_page_comment_api_error() {
1863        let server = wiremock::MockServer::start().await;
1864
1865        wiremock::Mock::given(wiremock::matchers::method("POST"))
1866            .and(wiremock::matchers::path("/wiki/api/v2/footer-comments"))
1867            .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
1868            .expect(1)
1869            .mount(&server)
1870            .await;
1871
1872        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1873        let api = ConfluenceApi::new(client);
1874        let adf = AdfDocument::new();
1875        let err = api.add_page_comment("12345", &adf).await.unwrap_err();
1876        assert!(err.to_string().contains("403"));
1877    }
1878
1879    // ── get_labels ────────────────────────────────────────────────
1880
1881    #[tokio::test]
1882    async fn get_labels_success() {
1883        let server = wiremock::MockServer::start().await;
1884
1885        wiremock::Mock::given(wiremock::matchers::method("GET"))
1886            .and(wiremock::matchers::path("/wiki/api/v2/pages/12345/labels"))
1887            .respond_with(
1888                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1889                    "results": [
1890                        {"id": "1", "name": "architecture", "prefix": "global"},
1891                        {"id": "2", "name": "draft", "prefix": "global"}
1892                    ]
1893                })),
1894            )
1895            .expect(1)
1896            .mount(&server)
1897            .await;
1898
1899        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1900        let api = ConfluenceApi::new(client);
1901        let labels = api.get_labels("12345").await.unwrap();
1902
1903        assert_eq!(labels.len(), 2);
1904        assert_eq!(labels[0].name, "architecture");
1905        assert_eq!(labels[0].prefix, "global");
1906        assert_eq!(labels[1].name, "draft");
1907    }
1908
1909    #[tokio::test]
1910    async fn get_labels_with_pagination() {
1911        let server = wiremock::MockServer::start().await;
1912
1913        // First page returns one label with a next link.
1914        wiremock::Mock::given(wiremock::matchers::method("GET"))
1915            .and(wiremock::matchers::path("/wiki/api/v2/pages/12345/labels"))
1916            .and(wiremock::matchers::query_param_is_missing("cursor"))
1917            .respond_with(
1918                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1919                    "results": [
1920                        {"id": "1", "name": "architecture", "prefix": "global"}
1921                    ],
1922                    "_links": {
1923                        "next": "/wiki/api/v2/pages/12345/labels?cursor=page2"
1924                    }
1925                })),
1926            )
1927            .expect(1)
1928            .mount(&server)
1929            .await;
1930
1931        // Second page returns another label with no next link.
1932        wiremock::Mock::given(wiremock::matchers::method("GET"))
1933            .and(wiremock::matchers::path("/wiki/api/v2/pages/12345/labels"))
1934            .and(wiremock::matchers::query_param("cursor", "page2"))
1935            .respond_with(
1936                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1937                    "results": [
1938                        {"id": "2", "name": "draft", "prefix": "global"}
1939                    ]
1940                })),
1941            )
1942            .expect(1)
1943            .mount(&server)
1944            .await;
1945
1946        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1947        let api = ConfluenceApi::new(client);
1948        let labels = api.get_labels("12345").await.unwrap();
1949
1950        assert_eq!(labels.len(), 2);
1951        assert_eq!(labels[0].name, "architecture");
1952        assert_eq!(labels[1].name, "draft");
1953    }
1954
1955    #[tokio::test]
1956    async fn get_labels_empty() {
1957        let server = wiremock::MockServer::start().await;
1958
1959        wiremock::Mock::given(wiremock::matchers::method("GET"))
1960            .and(wiremock::matchers::path("/wiki/api/v2/pages/12345/labels"))
1961            .respond_with(
1962                wiremock::ResponseTemplate::new(200)
1963                    .set_body_json(serde_json::json!({"results": []})),
1964            )
1965            .expect(1)
1966            .mount(&server)
1967            .await;
1968
1969        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1970        let api = ConfluenceApi::new(client);
1971        let labels = api.get_labels("12345").await.unwrap();
1972        assert!(labels.is_empty());
1973    }
1974
1975    #[tokio::test]
1976    async fn get_labels_api_error() {
1977        let server = wiremock::MockServer::start().await;
1978
1979        wiremock::Mock::given(wiremock::matchers::method("GET"))
1980            .and(wiremock::matchers::path("/wiki/api/v2/pages/99999/labels"))
1981            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
1982            .expect(1)
1983            .mount(&server)
1984            .await;
1985
1986        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1987        let api = ConfluenceApi::new(client);
1988        let err = api.get_labels("99999").await.unwrap_err();
1989        assert!(err.to_string().contains("404"));
1990    }
1991
1992    // ── add_labels ────────────────────────────────────────────────
1993
1994    #[tokio::test]
1995    async fn add_labels_success() {
1996        let server = wiremock::MockServer::start().await;
1997
1998        wiremock::Mock::given(wiremock::matchers::method("POST"))
1999            .and(wiremock::matchers::path(
2000                "/wiki/rest/api/content/12345/label",
2001            ))
2002            .respond_with(
2003                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2004                    "results": [
2005                        {"prefix": "global", "name": "architecture", "id": "1"},
2006                        {"prefix": "global", "name": "draft", "id": "2"}
2007                    ]
2008                })),
2009            )
2010            .expect(1)
2011            .mount(&server)
2012            .await;
2013
2014        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2015        let api = ConfluenceApi::new(client);
2016        let result = api
2017            .add_labels("12345", &["architecture".to_string(), "draft".to_string()])
2018            .await;
2019        assert!(result.is_ok());
2020    }
2021
2022    #[tokio::test]
2023    async fn add_labels_api_error() {
2024        let server = wiremock::MockServer::start().await;
2025
2026        wiremock::Mock::given(wiremock::matchers::method("POST"))
2027            .and(wiremock::matchers::path(
2028                "/wiki/rest/api/content/99999/label",
2029            ))
2030            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
2031            .expect(1)
2032            .mount(&server)
2033            .await;
2034
2035        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2036        let api = ConfluenceApi::new(client);
2037        let err = api
2038            .add_labels("99999", &["test".to_string()])
2039            .await
2040            .unwrap_err();
2041        assert!(err.to_string().contains("404"));
2042    }
2043
2044    // ── remove_label ──────────────────────────────────────────────
2045
2046    #[tokio::test]
2047    async fn remove_label_success() {
2048        let server = wiremock::MockServer::start().await;
2049
2050        wiremock::Mock::given(wiremock::matchers::method("DELETE"))
2051            .and(wiremock::matchers::path(
2052                "/wiki/rest/api/content/12345/label/architecture",
2053            ))
2054            .respond_with(wiremock::ResponseTemplate::new(204))
2055            .expect(1)
2056            .mount(&server)
2057            .await;
2058
2059        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2060        let api = ConfluenceApi::new(client);
2061        let result = api.remove_label("12345", "architecture").await;
2062        assert!(result.is_ok());
2063    }
2064
2065    #[tokio::test]
2066    async fn remove_label_api_error() {
2067        let server = wiremock::MockServer::start().await;
2068
2069        wiremock::Mock::given(wiremock::matchers::method("DELETE"))
2070            .and(wiremock::matchers::path(
2071                "/wiki/rest/api/content/99999/label/missing",
2072            ))
2073            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
2074            .expect(1)
2075            .mount(&server)
2076            .await;
2077
2078        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2079        let api = ConfluenceApi::new(client);
2080        let err = api.remove_label("99999", "missing").await.unwrap_err();
2081        assert!(err.to_string().contains("404"));
2082    }
2083
2084    // ── label struct serialization ────────────────────────────────
2085
2086    #[test]
2087    fn confluence_label_entry_deserialization() {
2088        let json = r#"{"id": "1", "name": "architecture", "prefix": "global"}"#;
2089        let entry: ConfluenceLabelEntry = serde_json::from_str(json).unwrap();
2090        assert_eq!(entry.id, "1");
2091        assert_eq!(entry.name, "architecture");
2092        assert_eq!(entry.prefix, "global");
2093    }
2094
2095    #[test]
2096    fn confluence_add_label_entry_serialization() {
2097        let entry = ConfluenceAddLabelEntry {
2098            prefix: "global".to_string(),
2099            name: "test".to_string(),
2100        };
2101        let json = serde_json::to_value(&entry).unwrap();
2102        assert_eq!(json["prefix"], "global");
2103        assert_eq!(json["name"], "test");
2104    }
2105}