Skip to main content

omni_dev/atlassian/
confluence_api.rs

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