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::path::Path;
9use std::pin::Pin;
10
11use anyhow::{Context, Result};
12use tokio_util::io::ReaderStream;
13use tracing::debug;
14
15use crate::atlassian::adf::AdfDocument;
16use crate::atlassian::adf_hints;
17use crate::atlassian::adf_schema;
18use crate::atlassian::adf_validated::ValidatedAdfDocument;
19use crate::atlassian::api::{AtlassianApi, ContentItem, ContentMetadata};
20use crate::atlassian::client::AtlassianClient;
21use crate::atlassian::confluence_types::{
22    ChildPage, CommentKind, ConfluenceAddCommentRequest, ConfluenceAddInlineCommentRequest,
23    ConfluenceAddLabelEntry, ConfluenceAttachment, ConfluenceAttachmentEntry,
24    ConfluenceAttachmentPage, ConfluenceAttachmentsResponse, ConfluenceChildrenResponse,
25    ConfluenceComment, ConfluenceCommentsResponse, ConfluenceCreateRequest,
26    ConfluenceCreateResponse, ConfluenceLabel, ConfluenceLabelsResponse, ConfluencePageResponse,
27    ConfluenceSpacePage, ConfluenceSpacePagesResponse, ConfluenceSpacePagesSummaryResponse,
28    ConfluenceSpaceResponse, ConfluenceSpacesResponse, ConfluenceUpdateBody,
29    ConfluenceUpdateRequest, ConfluenceUpdateVersion, ConfluenceV1AttachmentResponse,
30    ConfluenceVersionsResponse, InlineAnchor, InlineCommentProperties, MovePosition, MovedPage,
31    PageMetadata, PageSummary, PageSummaryPage, PageVersion, SinceFilter,
32};
33use crate::atlassian::error::AtlassianError;
34
35/// Builds an `anyhow::Error` for a non-success Confluence write/update/create
36/// response.
37///
38/// On HTTP 500, runs [`adf_schema::validate_document`] against the submitted
39/// ADF payload and, if a violation is found, returns
40/// [`AtlassianError::ApiRequestFailedWithDiagnosis`] with the first violation
41/// and a matching hint from [`adf_hints::hint_for`]. All other status codes
42/// (and 500 responses with no detected violation) fall back to the existing
43/// [`AtlassianError::ApiRequestFailed`] format.
44fn confluence_write_error(status: u16, body: String, body_adf: &AdfDocument) -> anyhow::Error {
45    if status == 500 {
46        if let Some(violation) = adf_schema::validate_document(body_adf).into_iter().next() {
47            let hint = adf_hints::hint_for(&violation).map(str::to_string);
48            return AtlassianError::ApiRequestFailedWithDiagnosis {
49                body,
50                diagnosis: violation,
51                hint,
52            }
53            .into();
54        }
55    }
56    AtlassianError::ApiRequestFailed { status, body }.into()
57}
58
59/// Confluence Cloud REST API v2 backend.
60pub struct ConfluenceApi {
61    client: AtlassianClient,
62}
63
64impl ConfluenceApi {
65    /// Creates a new Confluence API backend.
66    pub fn new(client: AtlassianClient) -> Self {
67        Self { client }
68    }
69}
70
71impl AtlassianApi for ConfluenceApi {
72    fn get_content<'a>(
73        &'a self,
74        id: &'a str,
75    ) -> Pin<Box<dyn Future<Output = Result<ContentItem>> + Send + 'a>> {
76        Box::pin(async move {
77            let url = format!(
78                "{}/wiki/api/v2/pages/{}?body-format=atlas_doc_format",
79                self.client.instance_url(),
80                id
81            );
82
83            let response = self
84                .client
85                .get_json(&url)
86                .await
87                .context("Failed to fetch Confluence page")?;
88
89            let page: ConfluencePageResponse = AtlassianClient::parse_json(
90                AtlassianClient::ensure_success(response).await?,
91                "Failed to parse Confluence page response",
92            )
93            .await?;
94
95            debug!(
96                page_id = page.id,
97                title = page.title,
98                "Fetched Confluence page"
99            );
100
101            // Confluence returns ADF as a JSON string — parse it to a Value.
102            let body_adf = if let Some(body) = &page.body {
103                if let Some(atlas_doc) = &body.atlas_doc_format {
104                    if tracing::enabled!(tracing::Level::TRACE) {
105                        if let Ok(pretty) =
106                            serde_json::from_str::<serde_json::Value>(&atlas_doc.value)
107                                .and_then(|v| serde_json::to_string_pretty(&v))
108                        {
109                            tracing::trace!("Original ADF from Confluence:\n{pretty}");
110                        }
111                    }
112                    Some(
113                        serde_json::from_str(&atlas_doc.value)
114                            .context("Failed to parse ADF from Confluence body")?,
115                    )
116                } else {
117                    None
118                }
119            } else {
120                None
121            };
122
123            // Resolve space key from space ID.
124            let space_key = self.resolve_space_key(&page.space_id).await?;
125
126            Ok(ContentItem {
127                id: page.id,
128                title: page.title,
129                body_adf,
130                metadata: ContentMetadata::Confluence {
131                    space_key,
132                    status: Some(page.status),
133                    version: page.version.map(|v| v.number),
134                    parent_id: page.parent_id,
135                },
136            })
137        })
138    }
139
140    fn update_content<'a>(
141        &'a self,
142        id: &'a str,
143        body_adf: &'a ValidatedAdfDocument,
144        title: Option<&'a str>,
145    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>> {
146        Box::pin(async move {
147            // Fetch current page to get version number and title.
148            let current = self.get_content(id).await?;
149            let current_version = match &current.metadata {
150                ContentMetadata::Confluence { version, .. } => version.unwrap_or(1),
151                ContentMetadata::Jira { .. } => 1,
152            };
153            let current_title = current.title;
154
155            let adf_json =
156                serde_json::to_string(body_adf).context("Failed to serialize ADF document")?;
157
158            debug!(
159                page_id = id,
160                version = current_version + 1,
161                adf_bytes = adf_json.len(),
162                "Updating Confluence page"
163            );
164            if tracing::enabled!(tracing::Level::TRACE) {
165                let pretty = serde_json::to_string_pretty(body_adf)
166                    .unwrap_or_else(|e| format!("<serialization error: {e}>"));
167                tracing::trace!("ADF body for update:\n{pretty}");
168            }
169
170            let update = ConfluenceUpdateRequest {
171                id: id.to_string(),
172                status: "current".to_string(),
173                title: title.unwrap_or(&current_title).to_string(),
174                body: ConfluenceUpdateBody {
175                    representation: "atlas_doc_format".to_string(),
176                    value: adf_json,
177                },
178                version: ConfluenceUpdateVersion {
179                    number: current_version + 1,
180                    message: None,
181                },
182            };
183
184            let url = format!("{}/wiki/api/v2/pages/{}", self.client.instance_url(), id);
185
186            let response = self
187                .client
188                .put_json(&url, &update)
189                .await
190                .context("Failed to update Confluence page")?;
191
192            if !response.status().is_success() {
193                let status = response.status().as_u16();
194                let body = response.text().await.unwrap_or_default();
195                debug!(status, body = %body, "Confluence update_content non-success");
196                return Err(confluence_write_error(status, body, body_adf));
197            }
198
199            Ok(())
200        })
201    }
202
203    fn verify_auth<'a>(&'a self) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>> {
204        // Reuse the JIRA /myself endpoint — same Atlassian Cloud instance.
205        Box::pin(async move {
206            let user = self.client.get_myself().await?;
207            Ok(user.display_name)
208        })
209    }
210
211    fn backend_name(&self) -> &'static str {
212        "confluence"
213    }
214}
215
216impl ConfluenceApi {
217    /// Resolves a space key to a space ID via the Confluence API.
218    pub async fn resolve_space_id(&self, space_key: &str) -> Result<String> {
219        let page = self.list_spaces(&[space_key], None, None, None, 1).await?;
220
221        page.results
222            .into_iter()
223            .next()
224            .map(|s| s.id)
225            .ok_or_else(|| anyhow::anyhow!("Space with key \"{space_key}\" not found"))
226    }
227
228    /// Lists Confluence spaces (one page at a time).
229    ///
230    /// Optional filters: `keys` (matches any of the given space keys; joined as
231    /// a single comma-separated query parameter), `type` (one of `"global"`,
232    /// `"personal"`, `"collaboration"`, `"knowledge_base"`), `status` (one of
233    /// `"current"`, `"archived"`). Pagination is *not* auto-drained: pass
234    /// [`ConfluenceSpacePage::next_cursor`] back as `cursor` to fetch the next
235    /// page.
236    pub async fn list_spaces(
237        &self,
238        keys: &[&str],
239        type_: Option<&str>,
240        status: Option<&str>,
241        cursor: Option<&str>,
242        limit: u32,
243    ) -> Result<ConfluenceSpacePage> {
244        let mut url = format!(
245            "{}/wiki/api/v2/spaces?limit={}",
246            self.client.instance_url(),
247            limit
248        );
249        if !keys.is_empty() {
250            let joined = keys.join(",");
251            url.push_str("&keys=");
252            url.push_str(&urlencoding(&joined));
253        }
254        if let Some(t) = type_ {
255            url.push_str("&type=");
256            url.push_str(&urlencoding(t));
257        }
258        if let Some(s) = status {
259            url.push_str("&status=");
260            url.push_str(&urlencoding(s));
261        }
262        if let Some(c) = cursor {
263            url.push_str("&cursor=");
264            url.push_str(&urlencoding(c));
265        }
266
267        let response = self
268            .client
269            .get_json(&url)
270            .await
271            .context("Failed to list Confluence spaces")?;
272
273        let resp: ConfluenceSpacesResponse = AtlassianClient::parse_json(
274            AtlassianClient::ensure_success(response).await?,
275            "Failed to parse Confluence spaces response",
276        )
277        .await?;
278
279        let next_cursor = resp
280            .links
281            .and_then(|l| l.next)
282            .and_then(|next_path| extract_cursor_from_next(&next_path));
283
284        let results = resp.results.into_iter().map(Into::into).collect();
285
286        Ok(ConfluenceSpacePage {
287            results,
288            next_cursor,
289        })
290    }
291
292    /// Enumerates pages within a Confluence space (one response at a time).
293    ///
294    /// Optional filters are passed through to the Confluence v2 API verbatim:
295    /// `status` (e.g. `current`, `archived`, `draft`, `trashed`) and `sort`
296    /// (e.g. `id`, `-id`, `title`, `-title`, `created-date`, `-created-date`,
297    /// `modified-date`, `-modified-date`). Pagination is *not* auto-drained:
298    /// pass [`PageSummaryPage::next_cursor`] back as `cursor` to fetch the
299    /// next page.
300    pub async fn list_space_pages(
301        &self,
302        space_id: &str,
303        status: Option<&str>,
304        sort: Option<&str>,
305        cursor: Option<&str>,
306        limit: u32,
307    ) -> Result<PageSummaryPage> {
308        let mut url = format!(
309            "{}/wiki/api/v2/spaces/{}/pages?limit={}",
310            self.client.instance_url(),
311            space_id,
312            limit
313        );
314        if let Some(s) = status {
315            url.push_str("&status=");
316            url.push_str(&urlencoding(s));
317        }
318        if let Some(s) = sort {
319            url.push_str("&sort=");
320            url.push_str(&urlencoding(s));
321        }
322        if let Some(c) = cursor {
323            url.push_str("&cursor=");
324            url.push_str(&urlencoding(c));
325        }
326
327        let response = self
328            .client
329            .get_json(&url)
330            .await
331            .context("Failed to list Confluence space pages")?;
332
333        let resp: ConfluenceSpacePagesSummaryResponse = AtlassianClient::parse_json(
334            AtlassianClient::ensure_success(response).await?,
335            "Failed to parse Confluence space pages response",
336        )
337        .await?;
338
339        let next_cursor = resp
340            .links
341            .and_then(|l| l.next)
342            .and_then(|next_path| extract_cursor_from_next(&next_path));
343
344        let results = resp
345            .results
346            .into_iter()
347            .map(|e| PageSummary {
348                id: e.id,
349                title: e.title,
350                status: e.status.unwrap_or_default(),
351                parent_id: e.parent_id,
352                author_id: e.author_id,
353                created_at: e.created_at,
354            })
355            .collect();
356
357        Ok(PageSummaryPage {
358            results,
359            next_cursor,
360        })
361    }
362
363    /// Creates a new Confluence page.
364    pub async fn create_page(
365        &self,
366        space_key: &str,
367        title: &str,
368        body_adf: &ValidatedAdfDocument,
369        parent_id: Option<&str>,
370    ) -> Result<String> {
371        let space_id = self.resolve_space_id(space_key).await?;
372
373        let adf_json =
374            serde_json::to_string(body_adf).context("Failed to serialize ADF document")?;
375
376        let request = ConfluenceCreateRequest {
377            space_id,
378            title: title.to_string(),
379            body: ConfluenceUpdateBody {
380                representation: "atlas_doc_format".to_string(),
381                value: adf_json,
382            },
383            parent_id: parent_id.map(String::from),
384            status: "current".to_string(),
385        };
386
387        let url = format!("{}/wiki/api/v2/pages", self.client.instance_url());
388
389        let response = self
390            .client
391            .post_json(&url, &request)
392            .await
393            .context("Failed to create Confluence page")?;
394
395        if !response.status().is_success() {
396            let status = response.status().as_u16();
397            let body = response.text().await.unwrap_or_default();
398            debug!(status, body = %body, "Confluence create_page non-success");
399            return Err(confluence_write_error(status, body, body_adf));
400        }
401
402        let resp: ConfluenceCreateResponse = response
403            .json()
404            .await
405            .context("Failed to parse Confluence create response")?;
406
407        Ok(resp.id)
408    }
409
410    /// Moves or reparents a Confluence page within its current space.
411    ///
412    /// Same-space only — cross-space moves are not supported by the v2 API.
413    /// Uses the v1 move endpoint (`PUT /wiki/rest/api/content/{id}/move/{position}/{target}`),
414    /// then re-fetches the page with `?include-ancestors=true` to populate
415    /// the returned [`MovedPage`].
416    pub async fn move_page(
417        &self,
418        page_id: &str,
419        target_id: &str,
420        position: MovePosition,
421    ) -> Result<MovedPage> {
422        let url = format!(
423            "{}/wiki/rest/api/content/{}/move/{}/{}",
424            self.client.instance_url(),
425            page_id,
426            position.as_str(),
427            target_id
428        );
429
430        let response = self
431            .client
432            .put_json(&url, &serde_json::json!({}))
433            .await
434            .context("Failed to send Confluence move request")?;
435
436        if !response.status().is_success() {
437            let status = response.status().as_u16();
438            let body = response.text().await.unwrap_or_default();
439            if status == 403 {
440                anyhow::bail!(
441                    "Move failed: insufficient permissions to move page {page_id} \
442                     relative to target {target_id}. Confluence response: {body}"
443                );
444            }
445            if status == 404 {
446                anyhow::bail!(
447                    "Move failed: page {page_id} or target {target_id} not found, \
448                     or insufficient permissions. Confluence response: {body}"
449                );
450            }
451            return Err(AtlassianError::ApiRequestFailed { status, body }.into());
452        }
453
454        let page = self.fetch_page_with_ancestors(page_id).await?;
455        Ok(MovedPage {
456            id: page.id,
457            title: page.title,
458            parent_id: page.parent_id,
459            ancestors: page.ancestors.into_iter().map(|a| a.id).collect(),
460        })
461    }
462
463    /// Fetches a Confluence page with its ancestors populated.
464    async fn fetch_page_with_ancestors(&self, id: &str) -> Result<ConfluencePageResponse> {
465        let url = format!(
466            "{}/wiki/api/v2/pages/{}?include-ancestors=true",
467            self.client.instance_url(),
468            id
469        );
470
471        let response = self
472            .client
473            .get_json(&url)
474            .await
475            .context("Failed to fetch Confluence page with ancestors")?;
476
477        let response = AtlassianClient::ensure_success(response).await?;
478
479        response
480            .json()
481            .await
482            .context("Failed to parse Confluence page response")
483    }
484
485    /// Deletes a Confluence page.
486    pub async fn delete_page(&self, id: &str, purge: bool) -> Result<()> {
487        let mut url = format!("{}/wiki/api/v2/pages/{}", self.client.instance_url(), id);
488        if purge {
489            url.push_str("?purge=true");
490        }
491
492        let response = self.client.delete(&url).await?;
493
494        if !response.status().is_success() {
495            let status = response.status().as_u16();
496            let body = response.text().await.unwrap_or_default();
497            if status == 404 {
498                anyhow::bail!(
499                    "Page {id} not found or insufficient permissions. \
500                     Confluence returns 404 when the API user lacks space-level delete permission. \
501                     Check Space Settings > Permissions."
502                );
503            }
504            return Err(AtlassianError::ApiRequestFailed { status, body }.into());
505        }
506
507        Ok(())
508    }
509
510    /// Fetches all child pages of a given page, handling pagination.
511    ///
512    /// Uses the v1 content API (`/wiki/rest/api/content/{id}/child/page`)
513    /// which is more widely supported than the v2 children endpoint.
514    pub async fn get_children(&self, page_id: &str) -> Result<Vec<ChildPage>> {
515        let mut all_children = Vec::new();
516        let mut url = format!(
517            "{}/wiki/rest/api/content/{}/child/page?limit=50",
518            self.client.instance_url(),
519            page_id
520        );
521
522        loop {
523            let response = self
524                .client
525                .get_json(&url)
526                .await
527                .context("Failed to fetch child pages")?;
528
529            let resp: ConfluenceChildrenResponse = AtlassianClient::parse_json(
530                AtlassianClient::ensure_success(response).await?,
531                "Failed to parse children response",
532            )
533            .await?;
534
535            let page_count = resp.results.len();
536            for child in resp.results {
537                all_children.push(ChildPage {
538                    id: child.id,
539                    title: child.title,
540                    status: child.status.unwrap_or_default(),
541                    parent_id: Some(page_id.to_string()),
542                    space_key: None,
543                });
544            }
545
546            match resp.links.and_then(|l| l.next) {
547                Some(next_path) if page_count > 0 => {
548                    url = format!("{}{}", self.client.instance_url(), next_path);
549                }
550                _ => break,
551            }
552        }
553
554        Ok(all_children)
555    }
556
557    /// Fetches top-level pages in a space (pages with no parent), handling pagination.
558    ///
559    /// Uses the v2 API endpoint `/wiki/api/v2/spaces/{space-id}/pages?depth=root`.
560    pub async fn get_space_root_pages(&self, space_id: &str) -> Result<Vec<ChildPage>> {
561        let mut all_pages = Vec::new();
562        let mut url = format!(
563            "{}/wiki/api/v2/spaces/{}/pages?depth=root&limit=50",
564            self.client.instance_url(),
565            space_id
566        );
567
568        loop {
569            let response = self
570                .client
571                .get_json(&url)
572                .await
573                .context("Failed to fetch space root pages")?;
574
575            let resp: ConfluenceSpacePagesResponse = AtlassianClient::parse_json(
576                AtlassianClient::ensure_success(response).await?,
577                "Failed to parse space pages response",
578            )
579            .await?;
580
581            let page_count = resp.results.len();
582            for entry in resp.results {
583                all_pages.push(ChildPage {
584                    id: entry.id,
585                    title: entry.title,
586                    status: entry.status.unwrap_or_default(),
587                    parent_id: entry.parent_id,
588                    space_key: None,
589                });
590            }
591
592            match resp.links.and_then(|l| l.next) {
593                Some(next_path) if page_count > 0 => {
594                    url = format!("{}{}", self.client.instance_url(), next_path);
595                }
596                _ => break,
597            }
598        }
599
600        Ok(all_pages)
601    }
602
603    /// Lists footer comments on a Confluence page, handling pagination.
604    pub async fn get_page_comments(&self, page_id: &str) -> Result<Vec<ConfluenceComment>> {
605        let url = format!(
606            "{}/wiki/api/v2/pages/{}/footer-comments?body-format=atlas_doc_format",
607            self.client.instance_url(),
608            page_id
609        );
610        self.fetch_comments_paginated(url, CommentKind::Footer)
611            .await
612    }
613
614    /// Lists inline comments on a Confluence page, handling pagination.
615    pub async fn get_page_inline_comments(&self, page_id: &str) -> Result<Vec<ConfluenceComment>> {
616        let url = format!(
617            "{}/wiki/api/v2/pages/{}/inline-comments?body-format=atlas_doc_format",
618            self.client.instance_url(),
619            page_id
620        );
621        self.fetch_comments_paginated(url, CommentKind::Inline)
622            .await
623    }
624
625    /// Lists the replies (child comments) of a comment.
626    ///
627    /// `kind` selects which Confluence v2 endpoint to hit: footer replies and
628    /// inline replies live on separate URLs. The returned comments are stamped
629    /// with the same `kind` as the parent — Confluence treats reply chains as
630    /// homogenous.
631    pub async fn get_comment_replies(
632        &self,
633        comment_id: &str,
634        kind: CommentKind,
635    ) -> Result<Vec<ConfluenceComment>> {
636        let url = format!(
637            "{}/wiki/api/v2/{}/{}/children?body-format=atlas_doc_format",
638            self.client.instance_url(),
639            kind.endpoint_segment(),
640            comment_id
641        );
642        self.fetch_comments_paginated(url, kind).await
643    }
644
645    /// Shared paginated GET for the comments and replies endpoints.
646    async fn fetch_comments_paginated(
647        &self,
648        mut url: String,
649        kind: CommentKind,
650    ) -> Result<Vec<ConfluenceComment>> {
651        let mut all_comments = Vec::new();
652
653        loop {
654            let response = self
655                .client
656                .get_json(&url)
657                .await
658                .context("Failed to fetch Confluence comments")?;
659
660            let resp: ConfluenceCommentsResponse = AtlassianClient::parse_json(
661                AtlassianClient::ensure_success(response).await?,
662                "Failed to parse Confluence comments response",
663            )
664            .await?;
665
666            let page_count = resp.results.len();
667            for c in resp.results {
668                let body_adf = c.body.and_then(|b| {
669                    b.atlas_doc_format
670                        .and_then(|a| serde_json::from_str(&a.value).ok())
671                });
672                let author = c
673                    .version
674                    .as_ref()
675                    .and_then(|v| v.author_id.clone())
676                    .unwrap_or_default();
677                let created = c.version.and_then(|v| v.created_at).unwrap_or_default();
678                let (inline_marker_ref, inline_original_selection) = match c.properties {
679                    Some(p) => (p.inline_marker_ref, p.inline_original_selection),
680                    None => (None, None),
681                };
682                all_comments.push(ConfluenceComment {
683                    id: c.id,
684                    author,
685                    kind,
686                    body_adf,
687                    created,
688                    inline_marker_ref,
689                    inline_original_selection,
690                });
691            }
692
693            match resp.links.and_then(|l| l.next) {
694                Some(next_path) if page_count > 0 => {
695                    url = format!("{}{}", self.client.instance_url(), next_path);
696                }
697                _ => break,
698            }
699        }
700
701        Ok(all_comments)
702    }
703
704    /// Adds a footer comment to a Confluence page.
705    pub async fn add_page_comment(
706        &self,
707        page_id: &str,
708        body_adf: &ValidatedAdfDocument,
709    ) -> Result<()> {
710        let adf_json =
711            serde_json::to_string(body_adf).context("Failed to serialize ADF document")?;
712
713        let request = ConfluenceAddCommentRequest {
714            page_id: page_id.to_string(),
715            body: ConfluenceUpdateBody {
716                representation: "atlas_doc_format".to_string(),
717                value: adf_json,
718            },
719        };
720
721        let url = format!("{}/wiki/api/v2/footer-comments", self.client.instance_url());
722
723        let response = self
724            .client
725            .post_json(&url, &request)
726            .await
727            .context("Failed to add Confluence page comment")?;
728
729        if !response.status().is_success() {
730            let status = response.status().as_u16();
731            let body = response.text().await.unwrap_or_default();
732            debug!(status, body = %body, "Confluence add_page_comment non-success");
733            return Err(confluence_write_error(status, body, body_adf));
734        }
735
736        Ok(())
737    }
738
739    /// Adds an inline comment anchored to a text selection on a Confluence page.
740    ///
741    /// `anchor` is typically produced by [`Self::resolve_anchor`], which counts
742    /// occurrences on the live page and validates that a 1-based `match_index`
743    /// the user supplied is in range.
744    pub async fn add_inline_page_comment(
745        &self,
746        page_id: &str,
747        body_adf: &ValidatedAdfDocument,
748        anchor: &InlineAnchor,
749    ) -> Result<()> {
750        let adf_json =
751            serde_json::to_string(body_adf).context("Failed to serialize ADF document")?;
752
753        let request = ConfluenceAddInlineCommentRequest {
754            page_id: page_id.to_string(),
755            body: ConfluenceUpdateBody {
756                representation: "atlas_doc_format".to_string(),
757                value: adf_json,
758            },
759            inline_comment_properties: InlineCommentProperties {
760                text_selection: anchor.text.clone(),
761                text_selection_match_count: anchor.match_count,
762                text_selection_match_index: anchor.match_index,
763            },
764        };
765
766        let url = format!("{}/wiki/api/v2/inline-comments", self.client.instance_url());
767
768        let response = self
769            .client
770            .post_json(&url, &request)
771            .await
772            .context("Failed to add Confluence inline comment")?;
773
774        if !response.status().is_success() {
775            let status = response.status().as_u16();
776            let body = response.text().await.unwrap_or_default();
777            debug!(status, body = %body, "Confluence add_inline_page_comment non-success");
778            return Err(confluence_write_error(status, body, body_adf));
779        }
780
781        Ok(())
782    }
783
784    /// Resolves an inline-comment anchor by counting `anchor_text` occurrences
785    /// in the live page body.
786    ///
787    /// `match_index_1based` is what the user typed (1-based) and is `None` if
788    /// they omitted the flag. The returned [`InlineAnchor`] is ready to hand to
789    /// [`Self::add_inline_page_comment`].
790    ///
791    /// # Errors
792    ///
793    /// - The anchor text does not appear on the page.
794    /// - The text appears more than once and no `--match-index` was supplied.
795    /// - The supplied `--match-index` is outside `1..=match_count`.
796    pub async fn resolve_anchor(
797        &self,
798        page_id: &str,
799        anchor_text: &str,
800        match_index_1based: Option<usize>,
801    ) -> Result<InlineAnchor> {
802        let page = self.get_content(page_id).await?;
803
804        let plain = match &page.body_adf {
805            Some(adf_value) => {
806                let adf: AdfDocument = serde_json::from_value(adf_value.clone())
807                    .context("Failed to parse page ADF for anchor resolution")?;
808                crate::atlassian::convert::adf_to_plain_text(&adf)
809            }
810            None => String::new(),
811        };
812
813        let match_count = count_non_overlapping(&plain, anchor_text);
814        resolve_anchor_indices(anchor_text, match_count, match_index_1based, page_id)
815    }
816
817    /// Resolves a space ID to a space key via the Confluence API.
818    async fn resolve_space_key(&self, space_id: &str) -> Result<String> {
819        let url = format!(
820            "{}/wiki/api/v2/spaces/{}",
821            self.client.instance_url(),
822            space_id
823        );
824
825        let response = self
826            .client
827            .get_json(&url)
828            .await
829            .context("Failed to fetch Confluence space")?;
830
831        if !response.status().is_success() {
832            // Fall back to using the space ID as key if lookup fails.
833            return Ok(space_id.to_string());
834        }
835
836        let space: ConfluenceSpaceResponse = response
837            .json()
838            .await
839            .context("Failed to parse Confluence space response")?;
840
841        Ok(space.key)
842    }
843
844    /// Fetches all labels on a Confluence page, handling pagination.
845    pub async fn get_labels(&self, page_id: &str) -> Result<Vec<ConfluenceLabel>> {
846        let mut all_labels = Vec::new();
847        let mut url = format!(
848            "{}/wiki/api/v2/pages/{}/labels",
849            self.client.instance_url(),
850            page_id
851        );
852
853        loop {
854            let response = self
855                .client
856                .get_json(&url)
857                .await
858                .context("Failed to fetch page labels")?;
859
860            let resp: ConfluenceLabelsResponse = AtlassianClient::parse_json(
861                AtlassianClient::ensure_success(response).await?,
862                "Failed to parse labels response",
863            )
864            .await?;
865
866            let page_count = resp.results.len();
867            for entry in resp.results {
868                all_labels.push(ConfluenceLabel {
869                    id: entry.id,
870                    name: entry.name,
871                    prefix: entry.prefix,
872                });
873            }
874
875            match resp.links.and_then(|l| l.next) {
876                Some(next_path) if page_count > 0 => {
877                    url = format!("{}{}", self.client.instance_url(), next_path);
878                }
879                _ => break,
880            }
881        }
882
883        Ok(all_labels)
884    }
885
886    /// Adds one or more labels to a Confluence page.
887    pub async fn add_labels(&self, page_id: &str, labels: &[String]) -> Result<()> {
888        let url = format!(
889            "{}/wiki/rest/api/content/{}/label",
890            self.client.instance_url(),
891            page_id
892        );
893
894        let body: Vec<ConfluenceAddLabelEntry> = labels
895            .iter()
896            .map(|name| ConfluenceAddLabelEntry {
897                prefix: "global".to_string(),
898                name: name.clone(),
899            })
900            .collect();
901
902        let response = self
903            .client
904            .post_json(&url, &body)
905            .await
906            .context("Failed to add labels")?;
907
908        AtlassianClient::ensure_success(response).await?;
909
910        Ok(())
911    }
912
913    /// Removes a label from a Confluence page.
914    pub async fn remove_label(&self, page_id: &str, label_name: &str) -> Result<()> {
915        let url = format!(
916            "{}/wiki/rest/api/content/{}/label/{}",
917            self.client.instance_url(),
918            page_id,
919            label_name
920        );
921
922        let response = self.client.delete(&url).await?;
923
924        AtlassianClient::ensure_success(response).await?;
925
926        Ok(())
927    }
928
929    /// Fetches lightweight metadata (id, title, current version) for a page.
930    ///
931    /// Cheaper than [`AtlassianApi::get_content`] because it skips the body
932    /// and the space-key lookup.
933    pub async fn get_page_metadata(&self, page_id: &str) -> Result<PageMetadata> {
934        let url = format!(
935            "{}/wiki/api/v2/pages/{}",
936            self.client.instance_url(),
937            page_id
938        );
939
940        let response = self
941            .client
942            .get_json(&url)
943            .await
944            .context("Failed to fetch Confluence page metadata")?;
945
946        let page: ConfluencePageResponse = AtlassianClient::parse_json(
947            AtlassianClient::ensure_success(response).await?,
948            "Failed to parse Confluence page response",
949        )
950        .await?;
951
952        Ok(PageMetadata {
953            id: page.id,
954            title: page.title,
955            current_version: page.version.map(|v| v.number),
956        })
957    }
958
959    /// Lists version history for a Confluence page, auto-paginated.
960    ///
961    /// Returns up to `limit` versions matching the optional `since` filter.
962    /// `limit = 0` means unlimited. The Confluence v2 API returns versions
963    /// newest-first, so encountering a version older than `since` ends
964    /// pagination early.
965    ///
966    /// The boolean in the return tuple is `truncated`: `true` when `limit`
967    /// was hit before the API was exhausted (more newer-than-`since`
968    /// versions exist upstream).
969    pub async fn list_page_versions(
970        &self,
971        page_id: &str,
972        since: Option<&SinceFilter>,
973        limit: u32,
974    ) -> Result<(Vec<PageVersion>, bool)> {
975        // Page size: cap at 100 per the v2 API; otherwise size to `limit`.
976        let page_size = if limit == 0 { 100 } else { limit.min(100) };
977        let mut url = format!(
978            "{}/wiki/api/v2/pages/{}/versions?limit={}",
979            self.client.instance_url(),
980            page_id,
981            page_size
982        );
983
984        let mut collected: Vec<PageVersion> = Vec::new();
985
986        loop {
987            let response = self
988                .client
989                .get_json(&url)
990                .await
991                .context("Failed to fetch Confluence page versions")?;
992
993            let resp: ConfluenceVersionsResponse = AtlassianClient::parse_json(
994                AtlassianClient::ensure_success(response).await?,
995                "Failed to parse Confluence versions response",
996            )
997            .await?;
998
999            let page_count = resp.results.len();
1000            let next_link = resp.links.and_then(|l| l.next);
1001
1002            for (idx, entry) in resp.results.into_iter().enumerate() {
1003                let version = PageVersion {
1004                    number: entry.number,
1005                    created_at: entry.created_at.unwrap_or_default(),
1006                    author_id: entry.author_id.unwrap_or_default(),
1007                    message: entry.message.unwrap_or_default(),
1008                    minor_edit: entry.minor_edit.unwrap_or(false),
1009                };
1010
1011                if let Some(filter) = since {
1012                    if !filter.matches(&version) {
1013                        // Versions are newest-first; nothing further can match.
1014                        return Ok((collected, false));
1015                    }
1016                }
1017
1018                collected.push(version);
1019                if limit > 0 && collected.len() as u32 >= limit {
1020                    // Truncated if more results exist on this page or in
1021                    // subsequent pages.
1022                    let more_on_page = idx + 1 < page_count;
1023                    let has_next = next_link.is_some();
1024                    return Ok((collected, more_on_page || has_next));
1025                }
1026            }
1027
1028            match next_link {
1029                Some(next_path) if page_count > 0 => {
1030                    url = format!("{}{}", self.client.instance_url(), next_path);
1031                }
1032                _ => return Ok((collected, false)),
1033            }
1034        }
1035    }
1036
1037    /// Uploads an attachment to a Confluence page from a local file path.
1038    ///
1039    /// Streams the file body — the file is never fully buffered in memory.
1040    /// Sends `X-Atlassian-Token: no-check` (Atlassian convention for
1041    /// state-changing multipart endpoints).
1042    ///
1043    /// Does not retry on 429: see [`AtlassianClient::post_multipart`].
1044    pub async fn upload_attachment(
1045        &self,
1046        page_id: &str,
1047        file_path: &Path,
1048        filename: Option<&str>,
1049        comment: Option<&str>,
1050        minor_edit: bool,
1051    ) -> Result<ConfluenceAttachment> {
1052        let metadata = tokio::fs::metadata(file_path)
1053            .await
1054            .with_context(|| format!("Failed to read file metadata for {}", file_path.display()))?;
1055        let size = metadata.len();
1056        let file = tokio::fs::File::open(file_path)
1057            .await
1058            .with_context(|| format!("Failed to open {}", file_path.display()))?;
1059
1060        let resolved_name = filename
1061            .map(str::to_string)
1062            .or_else(|| {
1063                file_path
1064                    .file_name()
1065                    .map(|s| s.to_string_lossy().into_owned())
1066            })
1067            .ok_or_else(|| anyhow::anyhow!("File path has no filename component"))?;
1068
1069        let mime = mime_guess::from_path(file_path).first_or_octet_stream();
1070
1071        let stream = ReaderStream::new(file);
1072        let body = reqwest::Body::wrap_stream(stream);
1073
1074        let part = reqwest::multipart::Part::stream_with_length(body, size)
1075            .file_name(resolved_name.clone())
1076            .mime_str(mime.essence_str())
1077            .with_context(|| format!("Invalid MIME type for {}", file_path.display()))?;
1078
1079        let mut form = reqwest::multipart::Form::new().part("file", part);
1080        if let Some(c) = comment {
1081            form = form.text("comment", c.to_string());
1082        }
1083        form = form.text("minorEdit", if minor_edit { "true" } else { "false" });
1084
1085        // Attachment creation is v1-only: the v2 API has no
1086        // attachment-creation endpoint, and POSTing to
1087        // `/wiki/api/v2/pages/{id}/attachments` returns HTTP 405.
1088        let url = format!(
1089            "{}/wiki/rest/api/content/{}/child/attachment",
1090            self.client.instance_url(),
1091            page_id
1092        );
1093
1094        let response = self
1095            .client
1096            .post_multipart(&url, form, &[("X-Atlassian-Token", "no-check")])
1097            .await?;
1098
1099        let resp: ConfluenceV1AttachmentResponse = AtlassianClient::parse_json(
1100            AtlassianClient::ensure_success(response).await?,
1101            "Failed to parse upload attachment response",
1102        )
1103        .await?;
1104
1105        let entry = resp
1106            .results
1107            .into_iter()
1108            .next()
1109            .ok_or_else(|| anyhow::anyhow!("Upload response contained no attachment"))?;
1110        Ok(entry.into())
1111    }
1112
1113    /// Lists attachments on a Confluence page (one page at a time).
1114    ///
1115    /// Unlike other v2 list helpers in this module, this does *not*
1116    /// auto-drain pagination: pass [`ConfluenceAttachmentPage::next_cursor`]
1117    /// back as `cursor` to fetch the next page.
1118    pub async fn list_attachments(
1119        &self,
1120        page_id: &str,
1121        cursor: Option<&str>,
1122        limit: u32,
1123    ) -> Result<ConfluenceAttachmentPage> {
1124        let mut url = format!(
1125            "{}/wiki/api/v2/pages/{}/attachments?limit={}",
1126            self.client.instance_url(),
1127            page_id,
1128            limit,
1129        );
1130        if let Some(c) = cursor {
1131            url.push_str("&cursor=");
1132            url.push_str(&urlencoding(c));
1133        }
1134
1135        let response = self
1136            .client
1137            .get_json(&url)
1138            .await
1139            .context("Failed to fetch page attachments")?;
1140
1141        let resp: ConfluenceAttachmentsResponse = AtlassianClient::parse_json(
1142            AtlassianClient::ensure_success(response).await?,
1143            "Failed to parse attachments response",
1144        )
1145        .await?;
1146
1147        let next_cursor = resp
1148            .links
1149            .and_then(|l| l.next)
1150            .and_then(|next_path| extract_cursor_from_next(&next_path));
1151
1152        let results = resp.results.into_iter().map(Into::into).collect();
1153
1154        Ok(ConfluenceAttachmentPage {
1155            results,
1156            next_cursor,
1157        })
1158    }
1159
1160    /// Deletes an attachment by ID.
1161    ///
1162    /// When `purge` is true, permanently purges (requires space admin);
1163    /// otherwise the attachment is moved to trash.
1164    pub async fn delete_attachment(&self, attachment_id: &str, purge: bool) -> Result<()> {
1165        let mut url = format!(
1166            "{}/wiki/api/v2/attachments/{}",
1167            self.client.instance_url(),
1168            attachment_id
1169        );
1170        if purge {
1171            url.push_str("?purge=true");
1172        }
1173
1174        let response = self.client.delete(&url).await?;
1175
1176        AtlassianClient::ensure_success(response).await?;
1177
1178        Ok(())
1179    }
1180
1181    /// Fetches metadata for a single attachment by ID (v2 API).
1182    ///
1183    /// Returns the same [`ConfluenceAttachment`] shape as
1184    /// [`ConfluenceApi::list_attachments`], including the `download_url`
1185    /// needed by [`ConfluenceApi::download_attachment_bytes`].
1186    pub async fn get_attachment(&self, attachment_id: &str) -> Result<ConfluenceAttachment> {
1187        let url = format!(
1188            "{}/wiki/api/v2/attachments/{}",
1189            self.client.instance_url(),
1190            attachment_id
1191        );
1192
1193        let response = self
1194            .client
1195            .get_json(&url)
1196            .await
1197            .context("Failed to fetch attachment metadata")?;
1198
1199        let entry: ConfluenceAttachmentEntry = AtlassianClient::parse_json(
1200            AtlassianClient::ensure_success(response).await?,
1201            "Failed to parse attachment response",
1202        )
1203        .await?;
1204        Ok(entry.into())
1205    }
1206
1207    /// Downloads the binary content of an attachment whose metadata is
1208    /// already in hand (e.g. from [`ConfluenceApi::list_attachments`]).
1209    ///
1210    /// The v1/v2 APIs report `download_url` as a path relative to the
1211    /// Confluence context root; it is resolved against the instance URL and
1212    /// fetched via the shared client, which follows the media-CDN redirect.
1213    pub async fn download_attachment_bytes(
1214        &self,
1215        attachment: &ConfluenceAttachment,
1216    ) -> Result<Vec<u8>> {
1217        let link = attachment.download_url.as_deref().ok_or_else(|| {
1218            anyhow::anyhow!(
1219                "Attachment {} ({}) has no download URL",
1220                attachment.id,
1221                attachment.title
1222            )
1223        })?;
1224        let url = resolve_attachment_download_url(self.client.instance_url(), link);
1225        self.client.get_bytes(&url).await
1226    }
1227
1228    /// Fetches an attachment's metadata by ID, then downloads its binary.
1229    ///
1230    /// Convenience for the single-attachment download path (CLI/MCP) where
1231    /// only the ID is known; the fan-out path uses
1232    /// [`ConfluenceApi::download_attachment_bytes`] directly to avoid an
1233    /// extra metadata round-trip per attachment.
1234    pub async fn download_attachment(
1235        &self,
1236        attachment_id: &str,
1237    ) -> Result<(ConfluenceAttachment, Vec<u8>)> {
1238        let attachment = self.get_attachment(attachment_id).await?;
1239        let bytes = self.download_attachment_bytes(&attachment).await?;
1240        Ok((attachment, bytes))
1241    }
1242
1243    /// Fetches a Confluence page pinned to a specific version number.
1244    ///
1245    /// Like [`AtlassianApi::get_content`] but returns the historical
1246    /// snapshot at `version` rather than the current head. Used by the
1247    /// version-comparison tooling to fetch each side of the diff
1248    /// independently — Confluence stores versions as immutable snapshots.
1249    pub async fn get_page_at_version(&self, id: &str, version: u32) -> Result<ContentItem> {
1250        let url = format!(
1251            "{}/wiki/api/v2/pages/{}?body-format=atlas_doc_format&version={}",
1252            self.client.instance_url(),
1253            id,
1254            version
1255        );
1256
1257        let response = self
1258            .client
1259            .get_json(&url)
1260            .await
1261            .context("Failed to fetch Confluence page version")?;
1262
1263        let page: ConfluencePageResponse = AtlassianClient::parse_json(
1264            AtlassianClient::ensure_success(response).await?,
1265            "Failed to parse Confluence page response",
1266        )
1267        .await?;
1268
1269        debug!(
1270            page_id = page.id,
1271            version,
1272            title = page.title,
1273            "Fetched Confluence page at specific version"
1274        );
1275
1276        let body_adf = if let Some(body) = &page.body {
1277            if let Some(atlas_doc) = &body.atlas_doc_format {
1278                Some(
1279                    serde_json::from_str(&atlas_doc.value)
1280                        .context("Failed to parse ADF from Confluence body")?,
1281                )
1282            } else {
1283                None
1284            }
1285        } else {
1286            None
1287        };
1288
1289        let space_key = self.resolve_space_key(&page.space_id).await?;
1290
1291        Ok(ContentItem {
1292            id: page.id,
1293            title: page.title,
1294            body_adf,
1295            metadata: ContentMetadata::Confluence {
1296                space_key,
1297                status: Some(page.status),
1298                version: page.version.map(|v| v.number),
1299                parent_id: page.parent_id,
1300            },
1301        })
1302    }
1303}
1304
1305/// Minimal application/x-www-form-urlencoded encoder for query-param values.
1306///
1307/// Only escapes the small set of characters that would otherwise corrupt the
1308/// query string (`& = + % # space`). Cursor values returned by Confluence are
1309/// opaque base64-ish blobs so this is sufficient.
1310fn urlencoding(s: &str) -> String {
1311    let mut out = String::with_capacity(s.len());
1312    for c in s.chars() {
1313        match c {
1314            '&' => out.push_str("%26"),
1315            '=' => out.push_str("%3D"),
1316            '+' => out.push_str("%2B"),
1317            '%' => out.push_str("%25"),
1318            '#' => out.push_str("%23"),
1319            ' ' => out.push_str("%20"),
1320            _ => out.push(c),
1321        }
1322    }
1323    out
1324}
1325
1326/// Resolves a Confluence attachment download link to an absolute URL.
1327///
1328/// The v1/v2 APIs report `downloadLink` as a path relative to the Confluence
1329/// context root (e.g. `/download/attachments/123/foo.png?...`). Absolute URLs
1330/// pass through unchanged; root-relative paths that already carry the `/wiki`
1331/// context prefix are joined to the bare instance origin; all other
1332/// root-relative (and bare-relative) paths get the `/wiki` prefix added.
1333fn resolve_attachment_download_url(instance_url: &str, link: &str) -> String {
1334    if link.starts_with("http://") || link.starts_with("https://") {
1335        return link.to_string();
1336    }
1337    let base = instance_url.trim_end_matches('/');
1338    if link == "/wiki" || link.starts_with("/wiki/") {
1339        format!("{base}{link}")
1340    } else if let Some(rest) = link.strip_prefix('/') {
1341        format!("{base}/wiki/{rest}")
1342    } else {
1343        format!("{base}/wiki/{link}")
1344    }
1345}
1346
1347/// Extracts the `cursor` query parameter value from a `_links.next` URL or path.
1348fn extract_cursor_from_next(next: &str) -> Option<String> {
1349    let query_start = next.find('?')?;
1350    let query = &next[query_start + 1..];
1351    for pair in query.split('&') {
1352        let mut it = pair.splitn(2, '=');
1353        let key = it.next()?;
1354        let value = it.next().unwrap_or("");
1355        if key == "cursor" {
1356            return Some(percent_decode(value));
1357        }
1358    }
1359    None
1360}
1361
1362/// Decodes a single `%xx`-style percent-encoded string back to UTF-8.
1363fn percent_decode(s: &str) -> String {
1364    let bytes = s.as_bytes();
1365    let mut out = Vec::with_capacity(bytes.len());
1366    let mut i = 0;
1367    while i < bytes.len() {
1368        if bytes[i] == b'%' && i + 2 < bytes.len() {
1369            let hi = (bytes[i + 1] as char).to_digit(16);
1370            let lo = (bytes[i + 2] as char).to_digit(16);
1371            if let (Some(hi), Some(lo)) = (hi, lo) {
1372                out.push(((hi << 4) | lo) as u8);
1373                i += 3;
1374                continue;
1375            }
1376        }
1377        out.push(bytes[i]);
1378        i += 1;
1379    }
1380    String::from_utf8_lossy(&out).into_owned()
1381}
1382
1383/// Resolves a user-supplied version reference against a list of
1384/// [`PageVersion`] records returned by [`ConfluenceApi::list_page_versions`].
1385///
1386/// Accepts:
1387/// - `"latest"` — the newest known version (`versions[0].number`).
1388/// - `"previous"` — the version immediately before `relative_to`.
1389/// - `"v-N"` (e.g. `"v-2"`) — the version `relative_to - N`.
1390/// - Numeric (`"5"`) — that exact version; must be present in `versions`.
1391/// - ISO 8601 date — the most recent version whose `created_at <=` the
1392///   given date. Detected when the input contains `-` or `T`.
1393///
1394/// `relative_to` anchors `"previous"` and `"v-N"`. Pass the resolved `to`
1395/// version when resolving `from`, so `previous` always means "one before
1396/// `to`" regardless of what `to` itself is.
1397///
1398/// `versions` must be ordered newest-first (the natural shape returned by
1399/// `list_page_versions`).
1400pub fn resolve_version(raw: &str, versions: &[PageVersion], relative_to: u32) -> Result<u32> {
1401    let trimmed = raw.trim();
1402    if trimmed.is_empty() {
1403        anyhow::bail!("version reference must not be empty");
1404    }
1405    if versions.is_empty() {
1406        anyhow::bail!("page has no versions");
1407    }
1408
1409    if trimmed.eq_ignore_ascii_case("latest") {
1410        return Ok(versions[0].number);
1411    }
1412    if trimmed.eq_ignore_ascii_case("previous") {
1413        return offset_from(relative_to, 1, versions);
1414    }
1415    if let Some(rest) = trimmed
1416        .strip_prefix("v-")
1417        .or_else(|| trimmed.strip_prefix("V-"))
1418    {
1419        let offset: u32 = rest.parse().with_context(|| {
1420            format!("Invalid relative version offset \"{trimmed}\"; expected v-N with N > 0")
1421        })?;
1422        if offset == 0 {
1423            anyhow::bail!("Relative version offset must be > 0; got \"{trimmed}\"");
1424        }
1425        return offset_from(relative_to, offset, versions);
1426    }
1427    if trimmed.chars().all(|c| c.is_ascii_digit()) {
1428        let n: u32 = trimmed
1429            .parse()
1430            .with_context(|| format!("Invalid version number \"{trimmed}\""))?;
1431        if !versions.iter().any(|v| v.number == n) {
1432            anyhow::bail!("Version {n} not found in page history");
1433        }
1434        return Ok(n);
1435    }
1436    if trimmed.contains('-') || trimmed.contains('T') {
1437        // ISO 8601 date: pick the latest version with created_at <= date.
1438        // `versions` is newest-first, so the first match wins.
1439        for v in versions {
1440            if !v.created_at.is_empty() && v.created_at.as_str() <= trimmed {
1441                return Ok(v.number);
1442            }
1443        }
1444        anyhow::bail!("No version found at or before \"{trimmed}\"");
1445    }
1446
1447    anyhow::bail!(
1448        "Could not parse \"{trimmed}\" as a version reference; expected \
1449         \"latest\", \"previous\", \"v-N\", a numeric version (e.g. \"5\"), \
1450         or an ISO 8601 date (e.g. \"2026-01-01T00:00:00Z\")"
1451    )
1452}
1453
1454fn offset_from(anchor: u32, offset: u32, versions: &[PageVersion]) -> Result<u32> {
1455    if anchor <= offset {
1456        anyhow::bail!(
1457            "Cannot resolve v-{offset} relative to version {anchor}: out of range \
1458             (would be {} or lower)",
1459            i64::from(anchor) - i64::from(offset)
1460        );
1461    }
1462    let target = anchor - offset;
1463    if !versions.iter().any(|v| v.number == target) {
1464        anyhow::bail!(
1465            "Version {target} not found in page history \
1466             (resolved from anchor {anchor} - {offset})"
1467        );
1468    }
1469    Ok(target)
1470}
1471
1472/// Counts non-overlapping occurrences of `needle` in `haystack`.
1473///
1474/// An empty `needle` returns 0 so anchor resolution rejects it as "not found".
1475fn count_non_overlapping(haystack: &str, needle: &str) -> usize {
1476    if needle.is_empty() {
1477        return 0;
1478    }
1479    let mut count = 0;
1480    let mut start = 0;
1481    while let Some(pos) = haystack[start..].find(needle) {
1482        count += 1;
1483        start += pos + needle.len();
1484    }
1485    count
1486}
1487
1488/// Picks an [`InlineAnchor`] match index given the live page's match count
1489/// and the user's 1-based `--match-index` (if any).
1490///
1491/// See [`ConfluenceApi::resolve_anchor`] for the full anchor-resolution
1492/// contract; this is the pure-logic half, factored out for direct unit
1493/// testing without an HTTP fixture.
1494fn resolve_anchor_indices(
1495    anchor_text: &str,
1496    match_count: usize,
1497    match_index_1based: Option<usize>,
1498    page_id: &str,
1499) -> Result<InlineAnchor> {
1500    if match_count == 0 {
1501        anyhow::bail!(
1502            "anchor text {anchor_text:?} not found on page {page_id}; \
1503             cannot create inline comment"
1504        );
1505    }
1506    let index = if let Some(i) = match_index_1based {
1507        if i == 0 || i > match_count {
1508            anyhow::bail!(
1509                "--match-index {i} out of range; anchor text {anchor_text:?} appears \
1510                 {match_count} time(s) on page {page_id} (valid range: 1..={match_count})"
1511            );
1512        }
1513        i - 1
1514    } else {
1515        if match_count > 1 {
1516            anyhow::bail!(
1517                "anchor text {anchor_text:?} appears {match_count} times on page {page_id}; \
1518                 specify --match-index <1..={match_count}> to choose which occurrence"
1519            );
1520        }
1521        0
1522    };
1523    Ok(InlineAnchor {
1524        text: anchor_text.to_string(),
1525        match_index: index,
1526        match_count,
1527    })
1528}
1529
1530#[cfg(test)]
1531#[allow(clippy::unwrap_used, clippy::expect_used)]
1532mod tests {
1533    use super::*;
1534    use crate::atlassian::confluence_types::{ConfluenceLabelEntry, ConfluenceVersionEntry};
1535
1536    #[test]
1537    fn confluence_api_backend_name() {
1538        let client =
1539            AtlassianClient::new("https://org.atlassian.net", "user@test.com", "token").unwrap();
1540        let api = ConfluenceApi::new(client);
1541        assert_eq!(api.backend_name(), "confluence");
1542    }
1543
1544    // ── CommentKind ────────────────────────────────────────────────
1545
1546    #[test]
1547    fn comment_kind_endpoint_segment() {
1548        assert_eq!(CommentKind::Footer.endpoint_segment(), "footer-comments");
1549        assert_eq!(CommentKind::Inline.endpoint_segment(), "inline-comments");
1550    }
1551
1552    #[test]
1553    fn comment_kind_serializes_lowercase() {
1554        let footer = serde_json::to_string(&CommentKind::Footer).unwrap();
1555        let inline = serde_json::to_string(&CommentKind::Inline).unwrap();
1556        assert_eq!(footer, "\"footer\"");
1557        assert_eq!(inline, "\"inline\"");
1558    }
1559
1560    #[test]
1561    fn comment_kind_display() {
1562        assert_eq!(CommentKind::Footer.to_string(), "footer");
1563        assert_eq!(CommentKind::Inline.to_string(), "inline");
1564    }
1565
1566    // ── count_non_overlapping ──────────────────────────────────────
1567
1568    #[test]
1569    fn count_non_overlapping_no_matches() {
1570        assert_eq!(count_non_overlapping("hello world", "foo"), 0);
1571    }
1572
1573    #[test]
1574    fn count_non_overlapping_single_match() {
1575        assert_eq!(count_non_overlapping("hello world", "world"), 1);
1576    }
1577
1578    #[test]
1579    fn count_non_overlapping_multiple_matches() {
1580        assert_eq!(count_non_overlapping("foo bar foo baz foo", "foo"), 3);
1581    }
1582
1583    #[test]
1584    fn count_non_overlapping_is_non_overlapping() {
1585        // "aa" in "aaaa" should be 2, not 3.
1586        assert_eq!(count_non_overlapping("aaaa", "aa"), 2);
1587    }
1588
1589    #[test]
1590    fn count_non_overlapping_empty_needle_is_zero() {
1591        // Anchor resolution must treat an empty anchor as "not found".
1592        assert_eq!(count_non_overlapping("anything", ""), 0);
1593    }
1594
1595    // ── resolve_anchor_indices ─────────────────────────────────────
1596
1597    #[test]
1598    fn resolve_anchor_indices_not_found_errors() {
1599        let err = resolve_anchor_indices("missing", 0, None, "PAGE").unwrap_err();
1600        let msg = err.to_string();
1601        assert!(msg.contains("not found"), "got: {msg}");
1602        assert!(msg.contains("PAGE"), "got: {msg}");
1603    }
1604
1605    #[test]
1606    fn resolve_anchor_indices_unique_match_uses_zero() {
1607        let a = resolve_anchor_indices("phrase", 1, None, "PAGE").unwrap();
1608        assert_eq!(a.match_index, 0);
1609        assert_eq!(a.match_count, 1);
1610        assert_eq!(a.text, "phrase");
1611    }
1612
1613    #[test]
1614    fn resolve_anchor_indices_ambiguous_without_match_index_errors() {
1615        let err = resolve_anchor_indices("phrase", 3, None, "PAGE").unwrap_err();
1616        let msg = err.to_string();
1617        assert!(msg.contains("appears 3 times"), "got: {msg}");
1618        assert!(msg.contains("--match-index"), "got: {msg}");
1619    }
1620
1621    #[test]
1622    fn resolve_anchor_indices_ambiguous_with_valid_match_index() {
1623        let a = resolve_anchor_indices("phrase", 3, Some(2), "PAGE").unwrap();
1624        assert_eq!(a.match_index, 1); // 2-based -> 1-based zero-indexed
1625        assert_eq!(a.match_count, 3);
1626    }
1627
1628    #[test]
1629    fn resolve_anchor_indices_match_index_zero_rejected() {
1630        let err = resolve_anchor_indices("phrase", 3, Some(0), "PAGE").unwrap_err();
1631        assert!(err.to_string().contains("out of range"));
1632    }
1633
1634    #[test]
1635    fn resolve_anchor_indices_match_index_too_large_rejected() {
1636        let err = resolve_anchor_indices("phrase", 3, Some(4), "PAGE").unwrap_err();
1637        assert!(err.to_string().contains("out of range"));
1638    }
1639
1640    // ── resolve_anchor (HTTP) ──────────────────────────────────────
1641
1642    async fn mock_page_with_text(server: &wiremock::MockServer, id: &str, text: &str) {
1643        let adf_value = format!(
1644            "{{\"version\":1,\"type\":\"doc\",\"content\":[{{\"type\":\"paragraph\",\"content\":[{{\"type\":\"text\",\"text\":{}}}]}}]}}",
1645            serde_json::Value::String(text.to_string())
1646        );
1647        wiremock::Mock::given(wiremock::matchers::method("GET"))
1648            .and(wiremock::matchers::path(format!("/wiki/api/v2/pages/{id}")))
1649            .respond_with(
1650                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1651                    "id": id,
1652                    "title": "Mock",
1653                    "status": "current",
1654                    "spaceId": "98",
1655                    "version": {"number": 1},
1656                    "body": {"atlas_doc_format": {"value": adf_value}}
1657                })),
1658            )
1659            .mount(server)
1660            .await;
1661        wiremock::Mock::given(wiremock::matchers::method("GET"))
1662            .and(wiremock::matchers::path("/wiki/api/v2/spaces/98"))
1663            .respond_with(
1664                wiremock::ResponseTemplate::new(200)
1665                    .set_body_json(serde_json::json!({"key": "ENG"})),
1666            )
1667            .mount(server)
1668            .await;
1669    }
1670
1671    fn mock_confluence_api(server: &wiremock::MockServer) -> ConfluenceApi {
1672        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1673        ConfluenceApi::new(client)
1674    }
1675
1676    #[tokio::test]
1677    async fn resolve_anchor_unique_match_succeeds() {
1678        let server = wiremock::MockServer::start().await;
1679        mock_page_with_text(&server, "12345", "the unique anchor phrase appears here").await;
1680        let api = mock_confluence_api(&server);
1681        let anchor = api
1682            .resolve_anchor("12345", "the unique anchor phrase", None)
1683            .await
1684            .unwrap();
1685        assert_eq!(anchor.match_count, 1);
1686        assert_eq!(anchor.match_index, 0);
1687    }
1688
1689    #[tokio::test]
1690    async fn resolve_anchor_not_found_errors() {
1691        let server = wiremock::MockServer::start().await;
1692        mock_page_with_text(&server, "12345", "nothing relevant").await;
1693        let api = mock_confluence_api(&server);
1694        let err = api
1695            .resolve_anchor("12345", "missing", None)
1696            .await
1697            .unwrap_err();
1698        assert!(err.to_string().contains("not found"));
1699    }
1700
1701    #[tokio::test]
1702    async fn resolve_anchor_on_body_less_page_errors_with_not_found() {
1703        // A Confluence page can come back with a null body; the resolver
1704        // must treat it as plain-text "" rather than panicking.
1705        let server = wiremock::MockServer::start().await;
1706        wiremock::Mock::given(wiremock::matchers::method("GET"))
1707            .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
1708            .respond_with(
1709                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1710                    "id": "12345",
1711                    "title": "Empty",
1712                    "status": "current",
1713                    "spaceId": "98",
1714                    "version": {"number": 1},
1715                    "body": null
1716                })),
1717            )
1718            .mount(&server)
1719            .await;
1720        wiremock::Mock::given(wiremock::matchers::method("GET"))
1721            .and(wiremock::matchers::path("/wiki/api/v2/spaces/98"))
1722            .respond_with(
1723                wiremock::ResponseTemplate::new(200)
1724                    .set_body_json(serde_json::json!({"key": "ENG"})),
1725            )
1726            .mount(&server)
1727            .await;
1728
1729        let api = mock_confluence_api(&server);
1730        let err = api
1731            .resolve_anchor("12345", "anything", None)
1732            .await
1733            .unwrap_err();
1734        assert!(err.to_string().contains("not found"));
1735    }
1736
1737    // ── add_inline_page_comment ────────────────────────────────────
1738
1739    #[tokio::test]
1740    async fn add_inline_page_comment_posts_anchor_payload() {
1741        let server = wiremock::MockServer::start().await;
1742        wiremock::Mock::given(wiremock::matchers::method("POST"))
1743            .and(wiremock::matchers::path("/wiki/api/v2/inline-comments"))
1744            .and(wiremock::matchers::body_partial_json(serde_json::json!({
1745                "pageId": "12345",
1746                "inlineCommentProperties": {
1747                    "textSelection": "phrase",
1748                    "textSelectionMatchCount": 2,
1749                    "textSelectionMatchIndex": 1
1750                }
1751            })))
1752            .respond_with(
1753                wiremock::ResponseTemplate::new(200)
1754                    .set_body_json(serde_json::json!({"id": "ic1"})),
1755            )
1756            .expect(1)
1757            .mount(&server)
1758            .await;
1759
1760        let api = mock_confluence_api(&server);
1761        let adf = ValidatedAdfDocument::empty();
1762        let anchor = InlineAnchor {
1763            text: "phrase".to_string(),
1764            match_index: 1,
1765            match_count: 2,
1766        };
1767        api.add_inline_page_comment("12345", &adf, &anchor)
1768            .await
1769            .unwrap();
1770    }
1771
1772    #[tokio::test]
1773    async fn add_inline_page_comment_propagates_http_error() {
1774        let server = wiremock::MockServer::start().await;
1775        wiremock::Mock::given(wiremock::matchers::method("POST"))
1776            .and(wiremock::matchers::path("/wiki/api/v2/inline-comments"))
1777            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("upstream"))
1778            .mount(&server)
1779            .await;
1780
1781        let api = mock_confluence_api(&server);
1782        let adf = ValidatedAdfDocument::empty();
1783        let anchor = InlineAnchor {
1784            text: "phrase".to_string(),
1785            match_index: 0,
1786            match_count: 1,
1787        };
1788        let err = api
1789            .add_inline_page_comment("12345", &adf, &anchor)
1790            .await
1791            .unwrap_err();
1792        assert!(err.to_string().contains("500"));
1793    }
1794
1795    // ── get_page_inline_comments (properties) ──────────────────────
1796
1797    #[tokio::test]
1798    async fn get_page_inline_comments_captures_marker_and_original_selection() {
1799        let server = wiremock::MockServer::start().await;
1800        wiremock::Mock::given(wiremock::matchers::method("GET"))
1801            .and(wiremock::matchers::path(
1802                "/wiki/api/v2/pages/12345/inline-comments",
1803            ))
1804            .respond_with(
1805                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1806                    "results": [{
1807                        "id": "ic1",
1808                        "version": {"authorId": "alice", "createdAt": "2026-01-01T00:00:00Z"},
1809                        "properties": {
1810                            "inlineMarkerRef": "marker-abc",
1811                            "inlineOriginalSelection": "the original highlight"
1812                        }
1813                    }]
1814                })),
1815            )
1816            .mount(&server)
1817            .await;
1818
1819        let api = mock_confluence_api(&server);
1820        let comments = api.get_page_inline_comments("12345").await.unwrap();
1821        assert_eq!(comments.len(), 1);
1822        assert_eq!(comments[0].inline_marker_ref.as_deref(), Some("marker-abc"));
1823        assert_eq!(
1824            comments[0].inline_original_selection.as_deref(),
1825            Some("the original highlight")
1826        );
1827    }
1828
1829    #[tokio::test]
1830    async fn get_page_comments_footer_has_no_inline_properties() {
1831        let server = wiremock::MockServer::start().await;
1832        wiremock::Mock::given(wiremock::matchers::method("GET"))
1833            .and(wiremock::matchers::path(
1834                "/wiki/api/v2/pages/12345/footer-comments",
1835            ))
1836            .respond_with(
1837                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1838                    "results": [{
1839                        "id": "fc1",
1840                        "version": {"authorId": "bob", "createdAt": "2026-01-01T00:00:00Z"}
1841                    }]
1842                })),
1843            )
1844            .mount(&server)
1845            .await;
1846
1847        let api = mock_confluence_api(&server);
1848        let comments = api.get_page_comments("12345").await.unwrap();
1849        assert_eq!(comments.len(), 1);
1850        assert!(comments[0].inline_marker_ref.is_none());
1851        assert!(comments[0].inline_original_selection.is_none());
1852    }
1853
1854    // ── get_comment_replies ────────────────────────────────────────
1855
1856    #[tokio::test]
1857    async fn get_comment_replies_inline_kind() {
1858        let server = wiremock::MockServer::start().await;
1859        wiremock::Mock::given(wiremock::matchers::method("GET"))
1860            .and(wiremock::matchers::path(
1861                "/wiki/api/v2/inline-comments/abc/children",
1862            ))
1863            .respond_with(
1864                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1865                    "results": [
1866                        {"id": "r1", "version": {"authorId": "alice", "createdAt": "2026-01-01T00:00:00Z"}}
1867                    ]
1868                })),
1869            )
1870            .mount(&server)
1871            .await;
1872
1873        let api = mock_confluence_api(&server);
1874        let replies = api
1875            .get_comment_replies("abc", CommentKind::Inline)
1876            .await
1877            .unwrap();
1878        assert_eq!(replies.len(), 1);
1879        assert_eq!(replies[0].kind, CommentKind::Inline);
1880    }
1881
1882    #[test]
1883    fn confluence_page_response_deserialization() {
1884        let json = r#"{
1885            "id": "12345",
1886            "title": "Test Page",
1887            "status": "current",
1888            "spaceId": "98765",
1889            "version": {"number": 3},
1890            "body": {
1891                "atlas_doc_format": {
1892                    "value": "{\"version\":1,\"type\":\"doc\",\"content\":[]}"
1893                }
1894            },
1895            "parentId": "11111"
1896        }"#;
1897        let page: ConfluencePageResponse = serde_json::from_str(json).unwrap();
1898        assert_eq!(page.id, "12345");
1899        assert_eq!(page.title, "Test Page");
1900        assert_eq!(page.status, "current");
1901        assert_eq!(page.space_id, "98765");
1902        assert_eq!(page.version.unwrap().number, 3);
1903        assert_eq!(page.parent_id.as_deref(), Some("11111"));
1904
1905        let body = page.body.unwrap();
1906        let atlas_doc = body.atlas_doc_format.unwrap();
1907        let adf: serde_json::Value = serde_json::from_str(&atlas_doc.value).unwrap();
1908        assert_eq!(adf["version"], 1);
1909        assert_eq!(adf["type"], "doc");
1910    }
1911
1912    #[test]
1913    fn confluence_page_response_minimal() {
1914        let json = r#"{
1915            "id": "99",
1916            "title": "Minimal",
1917            "status": "draft",
1918            "spaceId": "1"
1919        }"#;
1920        let page: ConfluencePageResponse = serde_json::from_str(json).unwrap();
1921        assert_eq!(page.id, "99");
1922        assert!(page.version.is_none());
1923        assert!(page.body.is_none());
1924        assert!(page.parent_id.is_none());
1925    }
1926
1927    #[test]
1928    fn confluence_update_request_serialization() {
1929        let req = ConfluenceUpdateRequest {
1930            id: "12345".to_string(),
1931            status: "current".to_string(),
1932            title: "Updated Title".to_string(),
1933            body: ConfluenceUpdateBody {
1934                representation: "atlas_doc_format".to_string(),
1935                value: r#"{"version":1,"type":"doc","content":[]}"#.to_string(),
1936            },
1937            version: ConfluenceUpdateVersion {
1938                number: 4,
1939                message: None,
1940            },
1941        };
1942
1943        let json = serde_json::to_value(&req).unwrap();
1944        assert_eq!(json["id"], "12345");
1945        assert_eq!(json["status"], "current");
1946        assert_eq!(json["title"], "Updated Title");
1947        assert_eq!(json["body"]["representation"], "atlas_doc_format");
1948        assert_eq!(json["version"]["number"], 4);
1949    }
1950
1951    #[test]
1952    fn confluence_update_version_with_message() {
1953        let req = ConfluenceUpdateRequest {
1954            id: "1".to_string(),
1955            status: "current".to_string(),
1956            title: "T".to_string(),
1957            body: ConfluenceUpdateBody {
1958                representation: "atlas_doc_format".to_string(),
1959                value: "{}".to_string(),
1960            },
1961            version: ConfluenceUpdateVersion {
1962                number: 2,
1963                message: Some("Updated via API".to_string()),
1964            },
1965        };
1966        let json = serde_json::to_value(&req).unwrap();
1967        assert_eq!(json["version"]["message"], "Updated via API");
1968    }
1969
1970    #[test]
1971    fn confluence_space_response_deserialization() {
1972        let json = r#"{"key": "ENG"}"#;
1973        let space: ConfluenceSpaceResponse = serde_json::from_str(json).unwrap();
1974        assert_eq!(space.key, "ENG");
1975    }
1976
1977    /// Builds a small ADF document containing `expand` nested inside `panel`,
1978    /// which violates Confluence's content model and should trigger the
1979    /// HTTP-500 diagnosis path. The validator emits a single violation at
1980    /// path `/0/0` (`expand` is the first child of the first top-level node).
1981    fn adf_with_panel_expand() -> AdfDocument {
1982        use crate::atlassian::adf::AdfNode;
1983        AdfDocument {
1984            version: 1,
1985            doc_type: "doc".to_string(),
1986            content: vec![AdfNode {
1987                node_type: "panel".to_string(),
1988                attrs: Some(serde_json::json!({"panelType": "info"})),
1989                content: Some(vec![AdfNode {
1990                    node_type: "expand".to_string(),
1991                    attrs: Some(serde_json::json!({"title": "details"})),
1992                    content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("x")])]),
1993                    text: None,
1994                    marks: None,
1995                    local_id: None,
1996                    parameters: None,
1997                }]),
1998                text: None,
1999                marks: None,
2000                local_id: None,
2001                parameters: None,
2002            }],
2003        }
2004    }
2005
2006    /// Helper to set up a wiremock server with the Confluence page and space endpoints.
2007    async fn setup_confluence_mock() -> (wiremock::MockServer, ConfluenceApi) {
2008        let server = wiremock::MockServer::start().await;
2009
2010        let page_json = serde_json::json!({
2011            "id": "12345",
2012            "title": "Test Page",
2013            "status": "current",
2014            "spaceId": "98765",
2015            "version": {"number": 3},
2016            "body": {
2017                "atlas_doc_format": {
2018                    "value": "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"Hello\"}]}]}"
2019                }
2020            },
2021            "parentId": "11111"
2022        });
2023
2024        wiremock::Mock::given(wiremock::matchers::method("GET"))
2025            .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
2026            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&page_json))
2027            .mount(&server)
2028            .await;
2029
2030        wiremock::Mock::given(wiremock::matchers::method("GET"))
2031            .and(wiremock::matchers::path("/wiki/api/v2/spaces/98765"))
2032            .respond_with(
2033                wiremock::ResponseTemplate::new(200)
2034                    .set_body_json(serde_json::json!({"key": "ENG"})),
2035            )
2036            .mount(&server)
2037            .await;
2038
2039        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2040        let api = ConfluenceApi::new(client);
2041
2042        (server, api)
2043    }
2044
2045    #[tokio::test]
2046    async fn get_content_success() {
2047        use crate::atlassian::api::{AtlassianApi, ContentMetadata};
2048
2049        let (_server, api) = setup_confluence_mock().await;
2050        let item = api.get_content("12345").await.unwrap();
2051
2052        assert_eq!(item.id, "12345");
2053        assert_eq!(item.title, "Test Page");
2054        assert!(item.body_adf.is_some());
2055        match &item.metadata {
2056            ContentMetadata::Confluence {
2057                space_key,
2058                status,
2059                version,
2060                parent_id,
2061            } => {
2062                assert_eq!(space_key, "ENG");
2063                assert_eq!(status.as_deref(), Some("current"));
2064                assert_eq!(*version, Some(3));
2065                assert_eq!(parent_id.as_deref(), Some("11111"));
2066            }
2067            ContentMetadata::Jira { .. } => panic!("Expected Confluence metadata"),
2068        }
2069    }
2070
2071    #[tokio::test]
2072    async fn get_content_api_error() {
2073        use crate::atlassian::api::AtlassianApi;
2074
2075        let server = wiremock::MockServer::start().await;
2076
2077        wiremock::Mock::given(wiremock::matchers::method("GET"))
2078            .and(wiremock::matchers::path("/wiki/api/v2/pages/99999"))
2079            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
2080            .mount(&server)
2081            .await;
2082
2083        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2084        let api = ConfluenceApi::new(client);
2085        let err = api.get_content("99999").await.unwrap_err();
2086        assert!(err.to_string().contains("404"));
2087    }
2088
2089    #[tokio::test]
2090    async fn get_content_no_body() {
2091        use crate::atlassian::api::AtlassianApi;
2092
2093        let server = wiremock::MockServer::start().await;
2094
2095        wiremock::Mock::given(wiremock::matchers::method("GET"))
2096            .and(wiremock::matchers::path("/wiki/api/v2/pages/55555"))
2097            .respond_with(
2098                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2099                    "id": "55555",
2100                    "title": "No Body",
2101                    "status": "draft",
2102                    "spaceId": "11111"
2103                })),
2104            )
2105            .mount(&server)
2106            .await;
2107
2108        wiremock::Mock::given(wiremock::matchers::method("GET"))
2109            .and(wiremock::matchers::path("/wiki/api/v2/spaces/11111"))
2110            .respond_with(
2111                wiremock::ResponseTemplate::new(200)
2112                    .set_body_json(serde_json::json!({"key": "DEV"})),
2113            )
2114            .mount(&server)
2115            .await;
2116
2117        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2118        let api = ConfluenceApi::new(client);
2119        let item = api.get_content("55555").await.unwrap();
2120        assert!(item.body_adf.is_none());
2121    }
2122
2123    #[tokio::test]
2124    async fn update_content_success() {
2125        use crate::atlassian::api::AtlassianApi;
2126
2127        let (server, api) = setup_confluence_mock().await;
2128
2129        wiremock::Mock::given(wiremock::matchers::method("PUT"))
2130            .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
2131            .respond_with(wiremock::ResponseTemplate::new(200))
2132            .mount(&server)
2133            .await;
2134
2135        let adf = ValidatedAdfDocument::empty();
2136        let result = api.update_content("12345", &adf, Some("New Title")).await;
2137        assert!(result.is_ok());
2138    }
2139
2140    #[tokio::test]
2141    async fn update_content_api_error() {
2142        use crate::atlassian::api::AtlassianApi;
2143
2144        let (server, api) = setup_confluence_mock().await;
2145
2146        wiremock::Mock::given(wiremock::matchers::method("PUT"))
2147            .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
2148            .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
2149            .mount(&server)
2150            .await;
2151
2152        let adf = ValidatedAdfDocument::empty();
2153        let err = api.update_content("12345", &adf, None).await.unwrap_err();
2154        assert!(err.to_string().contains("403"));
2155    }
2156
2157    #[tokio::test]
2158    async fn update_content_500_with_panel_expand_diagnoses() {
2159        use crate::atlassian::api::AtlassianApi;
2160
2161        let (server, api) = setup_confluence_mock().await;
2162
2163        wiremock::Mock::given(wiremock::matchers::method("PUT"))
2164            .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
2165            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string(
2166                "{\"errors\":[{\"status\":500,\"code\":\"INTERNAL_SERVER_ERROR\"}]}",
2167            ))
2168            .mount(&server)
2169            .await;
2170
2171        let adf = ValidatedAdfDocument::trust(adf_with_panel_expand());
2172        let err = api.update_content("12345", &adf, None).await.unwrap_err();
2173        let msg = err.to_string();
2174        assert!(
2175            msg.contains("Confluence API returned HTTP 500 (Internal Server Error)"),
2176            "missing 500 header in: {msg}"
2177        );
2178        assert!(
2179            msg.contains("Diagnosis:"),
2180            "missing Diagnosis line in: {msg}"
2181        );
2182        assert!(msg.contains("`expand`"), "missing child name in: {msg}");
2183        assert!(msg.contains("`panel`"), "missing parent name in: {msg}");
2184        assert!(msg.contains("Hint:"), "missing Hint line in: {msg}");
2185        assert!(
2186            !msg.contains("INTERNAL_SERVER_ERROR"),
2187            "raw response body should not be in user-facing message: {msg}"
2188        );
2189    }
2190
2191    #[tokio::test]
2192    async fn update_content_500_without_violation_falls_back() {
2193        use crate::atlassian::api::AtlassianApi;
2194
2195        let (server, api) = setup_confluence_mock().await;
2196
2197        wiremock::Mock::given(wiremock::matchers::method("PUT"))
2198            .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
2199            .respond_with(
2200                wiremock::ResponseTemplate::new(500).set_body_string("Internal Server Error"),
2201            )
2202            .mount(&server)
2203            .await;
2204
2205        // Empty (well-formed) document — no schema violations to surface.
2206        let adf = ValidatedAdfDocument::empty();
2207        let err = api.update_content("12345", &adf, None).await.unwrap_err();
2208        let msg = err.to_string();
2209        assert!(msg.contains("HTTP 500"), "missing status in: {msg}");
2210        assert!(
2211            msg.contains("Internal Server Error"),
2212            "fallback should include raw body: {msg}"
2213        );
2214        assert!(
2215            !msg.contains("Diagnosis:"),
2216            "fallback should not include diagnosis: {msg}"
2217        );
2218    }
2219
2220    #[tokio::test]
2221    async fn verify_auth_success() {
2222        use crate::atlassian::api::AtlassianApi;
2223
2224        let server = wiremock::MockServer::start().await;
2225
2226        wiremock::Mock::given(wiremock::matchers::method("GET"))
2227            .and(wiremock::matchers::path("/rest/api/3/myself"))
2228            .respond_with(
2229                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2230                    "displayName": "Alice",
2231                    "accountId": "abc123"
2232                })),
2233            )
2234            .mount(&server)
2235            .await;
2236
2237        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2238        let api = ConfluenceApi::new(client);
2239        let name = api.verify_auth().await.unwrap();
2240        assert_eq!(name, "Alice");
2241    }
2242
2243    #[tokio::test]
2244    async fn resolve_space_id_success() {
2245        let server = wiremock::MockServer::start().await;
2246
2247        wiremock::Mock::given(wiremock::matchers::method("GET"))
2248            .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
2249            .respond_with(
2250                wiremock::ResponseTemplate::new(200)
2251                    .set_body_json(serde_json::json!({"results": [{"id": "98765"}]})),
2252            )
2253            .mount(&server)
2254            .await;
2255
2256        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2257        let api = ConfluenceApi::new(client);
2258        let id = api.resolve_space_id("ENG").await.unwrap();
2259        assert_eq!(id, "98765");
2260    }
2261
2262    #[tokio::test]
2263    async fn resolve_space_id_not_found() {
2264        let server = wiremock::MockServer::start().await;
2265
2266        wiremock::Mock::given(wiremock::matchers::method("GET"))
2267            .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
2268            .respond_with(
2269                wiremock::ResponseTemplate::new(200)
2270                    .set_body_json(serde_json::json!({"results": []})),
2271            )
2272            .mount(&server)
2273            .await;
2274
2275        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2276        let api = ConfluenceApi::new(client);
2277        let err = api.resolve_space_id("NOPE").await.unwrap_err();
2278        assert!(err.to_string().contains("not found"));
2279    }
2280
2281    #[tokio::test]
2282    async fn resolve_space_id_api_error() {
2283        let server = wiremock::MockServer::start().await;
2284
2285        wiremock::Mock::given(wiremock::matchers::method("GET"))
2286            .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
2287            .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
2288            .mount(&server)
2289            .await;
2290
2291        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2292        let api = ConfluenceApi::new(client);
2293        let err = api.resolve_space_id("ENG").await.unwrap_err();
2294        assert!(err.to_string().contains("403"));
2295    }
2296
2297    #[tokio::test]
2298    async fn list_spaces_no_filters_success() {
2299        let server = wiremock::MockServer::start().await;
2300
2301        wiremock::Mock::given(wiremock::matchers::method("GET"))
2302            .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
2303            .and(wiremock::matchers::query_param("limit", "25"))
2304            .and(wiremock::matchers::query_param_is_missing("keys"))
2305            .and(wiremock::matchers::query_param_is_missing("type"))
2306            .and(wiremock::matchers::query_param_is_missing("status"))
2307            .and(wiremock::matchers::query_param_is_missing("cursor"))
2308            .respond_with(
2309                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2310                    "results": [
2311                        {
2312                            "id": "100",
2313                            "key": "ENG",
2314                            "name": "Engineering",
2315                            "type": "global",
2316                            "status": "current",
2317                            "homepageId": "200"
2318                        },
2319                        {
2320                            "id": "101",
2321                            "key": "OPS",
2322                            "name": "Operations",
2323                            "type": "global",
2324                            "status": "archived"
2325                        }
2326                    ]
2327                })),
2328            )
2329            .expect(1)
2330            .mount(&server)
2331            .await;
2332
2333        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2334        let api = ConfluenceApi::new(client);
2335        let page = api.list_spaces(&[], None, None, None, 25).await.unwrap();
2336        assert_eq!(page.results.len(), 2);
2337        assert_eq!(page.results[0].id, "100");
2338        assert_eq!(page.results[0].key, "ENG");
2339        assert_eq!(page.results[0].name, "Engineering");
2340        assert_eq!(page.results[0].type_, "global");
2341        assert_eq!(page.results[0].status, "current");
2342        assert_eq!(page.results[0].homepage_id.as_deref(), Some("200"));
2343        assert_eq!(page.results[1].status, "archived");
2344        assert!(page.results[1].homepage_id.is_none());
2345        assert!(page.next_cursor.is_none());
2346    }
2347
2348    #[tokio::test]
2349    async fn list_spaces_with_keys_filter() {
2350        let server = wiremock::MockServer::start().await;
2351
2352        wiremock::Mock::given(wiremock::matchers::method("GET"))
2353            .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
2354            .and(wiremock::matchers::query_param("keys", "ENG,DEV"))
2355            .respond_with(
2356                wiremock::ResponseTemplate::new(200)
2357                    .set_body_json(serde_json::json!({"results": []})),
2358            )
2359            .expect(1)
2360            .mount(&server)
2361            .await;
2362
2363        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2364        let api = ConfluenceApi::new(client);
2365        let page = api
2366            .list_spaces(&["ENG", "DEV"], None, None, None, 25)
2367            .await
2368            .unwrap();
2369        assert!(page.results.is_empty());
2370    }
2371
2372    #[tokio::test]
2373    async fn list_spaces_with_type_and_status() {
2374        let server = wiremock::MockServer::start().await;
2375
2376        wiremock::Mock::given(wiremock::matchers::method("GET"))
2377            .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
2378            .and(wiremock::matchers::query_param("type", "global"))
2379            .and(wiremock::matchers::query_param("status", "archived"))
2380            .respond_with(
2381                wiremock::ResponseTemplate::new(200)
2382                    .set_body_json(serde_json::json!({"results": []})),
2383            )
2384            .expect(1)
2385            .mount(&server)
2386            .await;
2387
2388        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2389        let api = ConfluenceApi::new(client);
2390        let page = api
2391            .list_spaces(&[], Some("global"), Some("archived"), None, 25)
2392            .await
2393            .unwrap();
2394        assert!(page.results.is_empty());
2395    }
2396
2397    #[tokio::test]
2398    async fn list_spaces_keys_combined_with_type() {
2399        let server = wiremock::MockServer::start().await;
2400
2401        wiremock::Mock::given(wiremock::matchers::method("GET"))
2402            .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
2403            .and(wiremock::matchers::query_param("keys", "ENG"))
2404            .and(wiremock::matchers::query_param("type", "knowledge_base"))
2405            .respond_with(
2406                wiremock::ResponseTemplate::new(200)
2407                    .set_body_json(serde_json::json!({"results": []})),
2408            )
2409            .expect(1)
2410            .mount(&server)
2411            .await;
2412
2413        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2414        let api = ConfluenceApi::new(client);
2415        let page = api
2416            .list_spaces(&["ENG"], Some("knowledge_base"), None, None, 25)
2417            .await
2418            .unwrap();
2419        assert!(page.results.is_empty());
2420    }
2421
2422    #[tokio::test]
2423    async fn list_spaces_pagination_round_trip() {
2424        let server = wiremock::MockServer::start().await;
2425
2426        // First page returns a _links.next pointing to cursor=PAGE2.
2427        wiremock::Mock::given(wiremock::matchers::method("GET"))
2428            .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
2429            .and(wiremock::matchers::query_param_is_missing("cursor"))
2430            .respond_with(
2431                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2432                    "results": [{
2433                        "id": "1", "key": "A", "name": "A",
2434                        "type": "global", "status": "current"
2435                    }],
2436                    "_links": {"next": "/wiki/api/v2/spaces?cursor=PAGE2&limit=25"}
2437                })),
2438            )
2439            .expect(1)
2440            .mount(&server)
2441            .await;
2442
2443        // Second page (cursor=PAGE2) returns the next batch with no further next.
2444        wiremock::Mock::given(wiremock::matchers::method("GET"))
2445            .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
2446            .and(wiremock::matchers::query_param("cursor", "PAGE2"))
2447            .respond_with(
2448                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2449                    "results": [{
2450                        "id": "2", "key": "B", "name": "B",
2451                        "type": "global", "status": "current"
2452                    }]
2453                })),
2454            )
2455            .expect(1)
2456            .mount(&server)
2457            .await;
2458
2459        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2460        let api = ConfluenceApi::new(client);
2461
2462        let first = api.list_spaces(&[], None, None, None, 25).await.unwrap();
2463        assert_eq!(first.next_cursor.as_deref(), Some("PAGE2"));
2464
2465        let second = api
2466            .list_spaces(&[], None, None, Some("PAGE2"), 25)
2467            .await
2468            .unwrap();
2469        assert_eq!(second.results.len(), 1);
2470        assert_eq!(second.results[0].id, "2");
2471        assert!(second.next_cursor.is_none());
2472    }
2473
2474    #[tokio::test]
2475    async fn list_spaces_homepage_id_absent() {
2476        let server = wiremock::MockServer::start().await;
2477
2478        wiremock::Mock::given(wiremock::matchers::method("GET"))
2479            .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
2480            .respond_with(
2481                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2482                    "results": [{
2483                        "id": "1", "key": "A", "name": "A",
2484                        "type": "global", "status": "current"
2485                    }]
2486                })),
2487            )
2488            .expect(1)
2489            .mount(&server)
2490            .await;
2491
2492        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2493        let api = ConfluenceApi::new(client);
2494        let page = api.list_spaces(&[], None, None, None, 25).await.unwrap();
2495        assert!(page.results[0].homepage_id.is_none());
2496    }
2497
2498    #[tokio::test]
2499    async fn list_spaces_api_error_403() {
2500        let server = wiremock::MockServer::start().await;
2501
2502        wiremock::Mock::given(wiremock::matchers::method("GET"))
2503            .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
2504            .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
2505            .expect(1)
2506            .mount(&server)
2507            .await;
2508
2509        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2510        let api = ConfluenceApi::new(client);
2511        let err = api
2512            .list_spaces(&[], None, None, None, 25)
2513            .await
2514            .unwrap_err();
2515        assert!(err.to_string().contains("403"));
2516    }
2517
2518    #[tokio::test]
2519    async fn list_space_pages_no_filters_success() {
2520        let server = wiremock::MockServer::start().await;
2521
2522        wiremock::Mock::given(wiremock::matchers::method("GET"))
2523            .and(wiremock::matchers::path("/wiki/api/v2/spaces/98765/pages"))
2524            .and(wiremock::matchers::query_param("limit", "25"))
2525            .and(wiremock::matchers::query_param_is_missing("status"))
2526            .and(wiremock::matchers::query_param_is_missing("sort"))
2527            .and(wiremock::matchers::query_param_is_missing("cursor"))
2528            .respond_with(
2529                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2530                    "results": [
2531                        {
2532                            "id": "777",
2533                            "title": "Home",
2534                            "status": "current",
2535                            "parentId": null,
2536                            "authorId": "u1",
2537                            "createdAt": "2024-01-02T03:04:05Z"
2538                        },
2539                        {
2540                            "id": "888",
2541                            "title": "Other",
2542                            "status": "current",
2543                            "parentId": "777",
2544                            "authorId": "u2",
2545                            "createdAt": "2024-02-02T03:04:05Z"
2546                        }
2547                    ]
2548                })),
2549            )
2550            .expect(1)
2551            .mount(&server)
2552            .await;
2553
2554        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2555        let api = ConfluenceApi::new(client);
2556        let page = api
2557            .list_space_pages("98765", None, None, None, 25)
2558            .await
2559            .unwrap();
2560        assert_eq!(page.results.len(), 2);
2561        assert_eq!(page.results[0].id, "777");
2562        assert_eq!(page.results[0].title, "Home");
2563        assert_eq!(page.results[0].status, "current");
2564        assert!(page.results[0].parent_id.is_none());
2565        assert_eq!(page.results[0].author_id.as_deref(), Some("u1"));
2566        assert_eq!(
2567            page.results[0].created_at.as_deref(),
2568            Some("2024-01-02T03:04:05Z")
2569        );
2570        assert_eq!(page.results[1].parent_id.as_deref(), Some("777"));
2571        assert!(page.next_cursor.is_none());
2572    }
2573
2574    #[tokio::test]
2575    async fn list_space_pages_with_status_and_sort() {
2576        let server = wiremock::MockServer::start().await;
2577
2578        wiremock::Mock::given(wiremock::matchers::method("GET"))
2579            .and(wiremock::matchers::path("/wiki/api/v2/spaces/98765/pages"))
2580            .and(wiremock::matchers::query_param("status", "archived"))
2581            .and(wiremock::matchers::query_param("sort", "-created-date"))
2582            .respond_with(
2583                wiremock::ResponseTemplate::new(200)
2584                    .set_body_json(serde_json::json!({"results": []})),
2585            )
2586            .expect(1)
2587            .mount(&server)
2588            .await;
2589
2590        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2591        let api = ConfluenceApi::new(client);
2592        let page = api
2593            .list_space_pages("98765", Some("archived"), Some("-created-date"), None, 25)
2594            .await
2595            .unwrap();
2596        assert!(page.results.is_empty());
2597    }
2598
2599    #[tokio::test]
2600    async fn list_space_pages_pagination_round_trip() {
2601        let server = wiremock::MockServer::start().await;
2602
2603        wiremock::Mock::given(wiremock::matchers::method("GET"))
2604            .and(wiremock::matchers::path("/wiki/api/v2/spaces/98765/pages"))
2605            .and(wiremock::matchers::query_param_is_missing("cursor"))
2606            .respond_with(
2607                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2608                    "results": [{
2609                        "id": "1", "title": "A", "status": "current"
2610                    }],
2611                    "_links": {
2612                        "next": "/wiki/api/v2/spaces/98765/pages?cursor=PAGE2&limit=25"
2613                    }
2614                })),
2615            )
2616            .expect(1)
2617            .mount(&server)
2618            .await;
2619
2620        wiremock::Mock::given(wiremock::matchers::method("GET"))
2621            .and(wiremock::matchers::path("/wiki/api/v2/spaces/98765/pages"))
2622            .and(wiremock::matchers::query_param("cursor", "PAGE2"))
2623            .respond_with(
2624                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2625                    "results": [{
2626                        "id": "2", "title": "B", "status": "current"
2627                    }]
2628                })),
2629            )
2630            .expect(1)
2631            .mount(&server)
2632            .await;
2633
2634        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2635        let api = ConfluenceApi::new(client);
2636
2637        let first = api
2638            .list_space_pages("98765", None, None, None, 25)
2639            .await
2640            .unwrap();
2641        assert_eq!(first.next_cursor.as_deref(), Some("PAGE2"));
2642
2643        let second = api
2644            .list_space_pages("98765", None, None, Some("PAGE2"), 25)
2645            .await
2646            .unwrap();
2647        assert_eq!(second.results.len(), 1);
2648        assert_eq!(second.results[0].id, "2");
2649        assert!(second.next_cursor.is_none());
2650    }
2651
2652    #[tokio::test]
2653    async fn list_space_pages_missing_optional_fields() {
2654        let server = wiremock::MockServer::start().await;
2655
2656        wiremock::Mock::given(wiremock::matchers::method("GET"))
2657            .and(wiremock::matchers::path("/wiki/api/v2/spaces/98765/pages"))
2658            .respond_with(
2659                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2660                    "results": [{"id": "1", "title": "Bare"}]
2661                })),
2662            )
2663            .expect(1)
2664            .mount(&server)
2665            .await;
2666
2667        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2668        let api = ConfluenceApi::new(client);
2669        let page = api
2670            .list_space_pages("98765", None, None, None, 25)
2671            .await
2672            .unwrap();
2673        assert_eq!(page.results.len(), 1);
2674        assert_eq!(page.results[0].id, "1");
2675        assert_eq!(page.results[0].status, "");
2676        assert!(page.results[0].parent_id.is_none());
2677        assert!(page.results[0].author_id.is_none());
2678        assert!(page.results[0].created_at.is_none());
2679    }
2680
2681    #[tokio::test]
2682    async fn list_space_pages_api_error_404() {
2683        let server = wiremock::MockServer::start().await;
2684
2685        wiremock::Mock::given(wiremock::matchers::method("GET"))
2686            .and(wiremock::matchers::path("/wiki/api/v2/spaces/99999/pages"))
2687            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
2688            .expect(1)
2689            .mount(&server)
2690            .await;
2691
2692        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2693        let api = ConfluenceApi::new(client);
2694        let err = api
2695            .list_space_pages("99999", None, None, None, 25)
2696            .await
2697            .unwrap_err();
2698        assert!(err.to_string().contains("404"));
2699    }
2700
2701    #[tokio::test]
2702    async fn list_space_pages_parse_error() {
2703        let server = wiremock::MockServer::start().await;
2704
2705        wiremock::Mock::given(wiremock::matchers::method("GET"))
2706            .and(wiremock::matchers::path("/wiki/api/v2/spaces/98765/pages"))
2707            .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
2708            .expect(1)
2709            .mount(&server)
2710            .await;
2711
2712        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2713        let api = ConfluenceApi::new(client);
2714        let err = api
2715            .list_space_pages("98765", None, None, None, 25)
2716            .await
2717            .unwrap_err();
2718        assert!(err.to_string().contains("Failed to parse"));
2719    }
2720
2721    /// Exercises the transport-failure Err branch of `get_json().await?` —
2722    /// covers the `.context("Failed to list Confluence space pages")?`
2723    /// propagation when the HTTP request itself can't reach the server.
2724    /// Uses the reserved `127.0.0.1:1` address (the same trick the dispatch
2725    /// tests use to provoke connection refused).
2726    #[tokio::test]
2727    async fn list_space_pages_transport_error() {
2728        let client = AtlassianClient::new("http://127.0.0.1:1", "user@test.com", "token").unwrap();
2729        let api = ConfluenceApi::new(client);
2730        let err = api
2731            .list_space_pages("98765", None, None, None, 25)
2732            .await
2733            .unwrap_err();
2734        assert!(err
2735            .to_string()
2736            .contains("Failed to list Confluence space pages"));
2737    }
2738
2739    #[tokio::test]
2740    async fn create_page_success() {
2741        let server = wiremock::MockServer::start().await;
2742
2743        // Space lookup
2744        wiremock::Mock::given(wiremock::matchers::method("GET"))
2745            .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
2746            .respond_with(
2747                wiremock::ResponseTemplate::new(200)
2748                    .set_body_json(serde_json::json!({"results": [{"id": "98765"}]})),
2749            )
2750            .mount(&server)
2751            .await;
2752
2753        // Create page
2754        wiremock::Mock::given(wiremock::matchers::method("POST"))
2755            .and(wiremock::matchers::path("/wiki/api/v2/pages"))
2756            .respond_with(
2757                wiremock::ResponseTemplate::new(200)
2758                    .set_body_json(serde_json::json!({"id": "54321"})),
2759            )
2760            .mount(&server)
2761            .await;
2762
2763        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2764        let api = ConfluenceApi::new(client);
2765        let adf = ValidatedAdfDocument::empty();
2766        let id = api
2767            .create_page("ENG", "New Page", &adf, None)
2768            .await
2769            .unwrap();
2770        assert_eq!(id, "54321");
2771    }
2772
2773    #[tokio::test]
2774    async fn create_page_with_parent() {
2775        let server = wiremock::MockServer::start().await;
2776
2777        wiremock::Mock::given(wiremock::matchers::method("GET"))
2778            .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
2779            .respond_with(
2780                wiremock::ResponseTemplate::new(200)
2781                    .set_body_json(serde_json::json!({"results": [{"id": "98765"}]})),
2782            )
2783            .mount(&server)
2784            .await;
2785
2786        wiremock::Mock::given(wiremock::matchers::method("POST"))
2787            .and(wiremock::matchers::path("/wiki/api/v2/pages"))
2788            .respond_with(
2789                wiremock::ResponseTemplate::new(200)
2790                    .set_body_json(serde_json::json!({"id": "54322"})),
2791            )
2792            .mount(&server)
2793            .await;
2794
2795        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2796        let api = ConfluenceApi::new(client);
2797        let adf = ValidatedAdfDocument::empty();
2798        let id = api
2799            .create_page("ENG", "Child Page", &adf, Some("11111"))
2800            .await
2801            .unwrap();
2802        assert_eq!(id, "54322");
2803    }
2804
2805    #[tokio::test]
2806    async fn create_page_api_error() {
2807        let server = wiremock::MockServer::start().await;
2808
2809        wiremock::Mock::given(wiremock::matchers::method("GET"))
2810            .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
2811            .respond_with(
2812                wiremock::ResponseTemplate::new(200)
2813                    .set_body_json(serde_json::json!({"results": [{"id": "98765"}]})),
2814            )
2815            .mount(&server)
2816            .await;
2817
2818        wiremock::Mock::given(wiremock::matchers::method("POST"))
2819            .and(wiremock::matchers::path("/wiki/api/v2/pages"))
2820            .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Bad Request"))
2821            .mount(&server)
2822            .await;
2823
2824        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2825        let api = ConfluenceApi::new(client);
2826        let adf = ValidatedAdfDocument::empty();
2827        let err = api
2828            .create_page("ENG", "Fail", &adf, None)
2829            .await
2830            .unwrap_err();
2831        assert!(err.to_string().contains("400"));
2832    }
2833
2834    #[tokio::test]
2835    async fn create_page_500_with_panel_expand_diagnoses() {
2836        let server = wiremock::MockServer::start().await;
2837
2838        wiremock::Mock::given(wiremock::matchers::method("GET"))
2839            .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
2840            .respond_with(
2841                wiremock::ResponseTemplate::new(200)
2842                    .set_body_json(serde_json::json!({"results": [{"id": "98765"}]})),
2843            )
2844            .mount(&server)
2845            .await;
2846
2847        wiremock::Mock::given(wiremock::matchers::method("POST"))
2848            .and(wiremock::matchers::path("/wiki/api/v2/pages"))
2849            .respond_with(
2850                wiremock::ResponseTemplate::new(500).set_body_string("Internal Server Error"),
2851            )
2852            .mount(&server)
2853            .await;
2854
2855        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2856        let api = ConfluenceApi::new(client);
2857        let adf = ValidatedAdfDocument::trust(adf_with_panel_expand());
2858        let err = api.create_page("ENG", "Bad", &adf, None).await.unwrap_err();
2859        let msg = err.to_string();
2860        assert!(msg.contains("HTTP 500"), "missing status in: {msg}");
2861        assert!(
2862            msg.contains("Diagnosis:"),
2863            "missing Diagnosis line in: {msg}"
2864        );
2865        assert!(msg.contains("`expand`"), "missing child name in: {msg}");
2866        assert!(msg.contains("`panel`"), "missing parent name in: {msg}");
2867        assert!(msg.contains("Hint:"), "missing Hint line in: {msg}");
2868    }
2869
2870    #[tokio::test]
2871    async fn delete_page_success() {
2872        let server = wiremock::MockServer::start().await;
2873
2874        wiremock::Mock::given(wiremock::matchers::method("DELETE"))
2875            .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
2876            .respond_with(wiremock::ResponseTemplate::new(204))
2877            .expect(1)
2878            .mount(&server)
2879            .await;
2880
2881        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2882        let api = ConfluenceApi::new(client);
2883        let result = api.delete_page("12345", false).await;
2884        assert!(result.is_ok());
2885    }
2886
2887    #[tokio::test]
2888    async fn delete_page_with_purge() {
2889        let server = wiremock::MockServer::start().await;
2890
2891        wiremock::Mock::given(wiremock::matchers::method("DELETE"))
2892            .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
2893            .and(wiremock::matchers::query_param("purge", "true"))
2894            .respond_with(wiremock::ResponseTemplate::new(204))
2895            .expect(1)
2896            .mount(&server)
2897            .await;
2898
2899        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2900        let api = ConfluenceApi::new(client);
2901        let result = api.delete_page("12345", true).await;
2902        assert!(result.is_ok());
2903    }
2904
2905    #[tokio::test]
2906    async fn delete_page_not_found_hints_permissions() {
2907        let server = wiremock::MockServer::start().await;
2908
2909        wiremock::Mock::given(wiremock::matchers::method("DELETE"))
2910            .and(wiremock::matchers::path("/wiki/api/v2/pages/99999"))
2911            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
2912            .expect(1)
2913            .mount(&server)
2914            .await;
2915
2916        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2917        let api = ConfluenceApi::new(client);
2918        let err = api.delete_page("99999", false).await.unwrap_err();
2919        let msg = err.to_string();
2920        assert!(msg.contains("not found or insufficient permissions"));
2921        assert!(msg.contains("Space Settings"));
2922    }
2923
2924    #[tokio::test]
2925    async fn delete_page_forbidden() {
2926        let server = wiremock::MockServer::start().await;
2927
2928        wiremock::Mock::given(wiremock::matchers::method("DELETE"))
2929            .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
2930            .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
2931            .expect(1)
2932            .mount(&server)
2933            .await;
2934
2935        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2936        let api = ConfluenceApi::new(client);
2937        let err = api.delete_page("12345", false).await.unwrap_err();
2938        assert!(err.to_string().contains("403"));
2939    }
2940
2941    // ── move_page ──────────────────────────────────────────────────
2942
2943    #[test]
2944    fn move_position_as_str() {
2945        assert_eq!(MovePosition::Append.as_str(), "append");
2946        assert_eq!(MovePosition::Before.as_str(), "before");
2947        assert_eq!(MovePosition::After.as_str(), "after");
2948    }
2949
2950    /// Mounts the post-move ancestor fetch (`GET /wiki/api/v2/pages/{id}?include-ancestors=true`).
2951    async fn mount_ancestor_fetch(server: &wiremock::MockServer, id: &str, parent_id: &str) {
2952        wiremock::Mock::given(wiremock::matchers::method("GET"))
2953            .and(wiremock::matchers::path(format!("/wiki/api/v2/pages/{id}")))
2954            .and(wiremock::matchers::query_param("include-ancestors", "true"))
2955            .respond_with(
2956                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2957                    "id": id,
2958                    "title": "Moved Page",
2959                    "status": "current",
2960                    "spaceId": "98765",
2961                    "parentId": parent_id,
2962                    "ancestors": [
2963                        {"id": "10"},
2964                        {"id": parent_id}
2965                    ]
2966                })),
2967            )
2968            .mount(server)
2969            .await;
2970    }
2971
2972    #[tokio::test]
2973    async fn move_page_append_success() {
2974        let server = wiremock::MockServer::start().await;
2975
2976        wiremock::Mock::given(wiremock::matchers::method("PUT"))
2977            .and(wiremock::matchers::path(
2978                "/wiki/rest/api/content/12345/move/append/456",
2979            ))
2980            .respond_with(
2981                wiremock::ResponseTemplate::new(200)
2982                    .set_body_json(serde_json::json!({"pageId": "12345"})),
2983            )
2984            .expect(1)
2985            .mount(&server)
2986            .await;
2987        mount_ancestor_fetch(&server, "12345", "456").await;
2988
2989        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2990        let api = ConfluenceApi::new(client);
2991        let moved = api
2992            .move_page("12345", "456", MovePosition::Append)
2993            .await
2994            .unwrap();
2995        assert_eq!(moved.id, "12345");
2996        assert_eq!(moved.title, "Moved Page");
2997        assert_eq!(moved.parent_id.as_deref(), Some("456"));
2998        assert_eq!(moved.ancestors, vec!["10".to_string(), "456".to_string()]);
2999    }
3000
3001    #[tokio::test]
3002    async fn move_page_before_success() {
3003        let server = wiremock::MockServer::start().await;
3004
3005        wiremock::Mock::given(wiremock::matchers::method("PUT"))
3006            .and(wiremock::matchers::path(
3007                "/wiki/rest/api/content/12345/move/before/456",
3008            ))
3009            .respond_with(
3010                wiremock::ResponseTemplate::new(200)
3011                    .set_body_json(serde_json::json!({"pageId": "12345"})),
3012            )
3013            .expect(1)
3014            .mount(&server)
3015            .await;
3016        mount_ancestor_fetch(&server, "12345", "789").await;
3017
3018        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3019        let api = ConfluenceApi::new(client);
3020        let moved = api
3021            .move_page("12345", "456", MovePosition::Before)
3022            .await
3023            .unwrap();
3024        assert_eq!(moved.parent_id.as_deref(), Some("789"));
3025    }
3026
3027    #[tokio::test]
3028    async fn move_page_after_success() {
3029        let server = wiremock::MockServer::start().await;
3030
3031        wiremock::Mock::given(wiremock::matchers::method("PUT"))
3032            .and(wiremock::matchers::path(
3033                "/wiki/rest/api/content/12345/move/after/456",
3034            ))
3035            .respond_with(
3036                wiremock::ResponseTemplate::new(200)
3037                    .set_body_json(serde_json::json!({"pageId": "12345"})),
3038            )
3039            .expect(1)
3040            .mount(&server)
3041            .await;
3042        mount_ancestor_fetch(&server, "12345", "789").await;
3043
3044        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3045        let api = ConfluenceApi::new(client);
3046        let moved = api
3047            .move_page("12345", "456", MovePosition::After)
3048            .await
3049            .unwrap();
3050        assert_eq!(moved.id, "12345");
3051    }
3052
3053    #[tokio::test]
3054    async fn move_page_forbidden_surfaces_reason() {
3055        let server = wiremock::MockServer::start().await;
3056
3057        wiremock::Mock::given(wiremock::matchers::method("PUT"))
3058            .and(wiremock::matchers::path(
3059                "/wiki/rest/api/content/12345/move/append/456",
3060            ))
3061            .respond_with(
3062                wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
3063                    "errors": [{"detail": "User cannot move page"}]
3064                })),
3065            )
3066            .expect(1)
3067            .mount(&server)
3068            .await;
3069
3070        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3071        let api = ConfluenceApi::new(client);
3072        let err = api
3073            .move_page("12345", "456", MovePosition::Append)
3074            .await
3075            .unwrap_err();
3076        let msg = err.to_string();
3077        assert!(msg.contains("insufficient permissions"));
3078        assert!(msg.contains("User cannot move page"));
3079    }
3080
3081    #[tokio::test]
3082    async fn move_page_not_found() {
3083        let server = wiremock::MockServer::start().await;
3084
3085        wiremock::Mock::given(wiremock::matchers::method("PUT"))
3086            .and(wiremock::matchers::path(
3087                "/wiki/rest/api/content/99999/move/append/456",
3088            ))
3089            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Page not found"))
3090            .expect(1)
3091            .mount(&server)
3092            .await;
3093
3094        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3095        let api = ConfluenceApi::new(client);
3096        let err = api
3097            .move_page("99999", "456", MovePosition::Append)
3098            .await
3099            .unwrap_err();
3100        let msg = err.to_string();
3101        assert!(msg.contains("not found"));
3102        assert!(msg.contains("Page not found"));
3103    }
3104
3105    #[tokio::test]
3106    async fn move_page_other_error_falls_through_to_generic() {
3107        let server = wiremock::MockServer::start().await;
3108
3109        wiremock::Mock::given(wiremock::matchers::method("PUT"))
3110            .and(wiremock::matchers::path(
3111                "/wiki/rest/api/content/12345/move/append/456",
3112            ))
3113            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
3114            .expect(1)
3115            .mount(&server)
3116            .await;
3117
3118        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3119        let api = ConfluenceApi::new(client);
3120        let err = api
3121            .move_page("12345", "456", MovePosition::Append)
3122            .await
3123            .unwrap_err();
3124        assert!(err.to_string().contains("500"));
3125    }
3126
3127    #[tokio::test]
3128    async fn move_page_ancestor_fetch_failure_is_propagated() {
3129        let server = wiremock::MockServer::start().await;
3130
3131        wiremock::Mock::given(wiremock::matchers::method("PUT"))
3132            .and(wiremock::matchers::path(
3133                "/wiki/rest/api/content/12345/move/append/456",
3134            ))
3135            .respond_with(
3136                wiremock::ResponseTemplate::new(200)
3137                    .set_body_json(serde_json::json!({"pageId": "12345"})),
3138            )
3139            .expect(1)
3140            .mount(&server)
3141            .await;
3142        wiremock::Mock::given(wiremock::matchers::method("GET"))
3143            .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
3144            .and(wiremock::matchers::query_param("include-ancestors", "true"))
3145            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("ancestor boom"))
3146            .expect(1)
3147            .mount(&server)
3148            .await;
3149
3150        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3151        let api = ConfluenceApi::new(client);
3152        let err = api
3153            .move_page("12345", "456", MovePosition::Append)
3154            .await
3155            .unwrap_err();
3156        assert!(err.to_string().contains("500"));
3157    }
3158
3159    #[tokio::test]
3160    async fn get_children_success() {
3161        let server = wiremock::MockServer::start().await;
3162
3163        wiremock::Mock::given(wiremock::matchers::method("GET"))
3164            .and(wiremock::matchers::path(
3165                "/wiki/rest/api/content/12345/child/page",
3166            ))
3167            .respond_with(
3168                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3169                    "results": [
3170                        {"id": "111", "title": "Child One"},
3171                        {"id": "222", "title": "Child Two"}
3172                    ]
3173                })),
3174            )
3175            .expect(1)
3176            .mount(&server)
3177            .await;
3178
3179        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3180        let api = ConfluenceApi::new(client);
3181        let children = api.get_children("12345").await.unwrap();
3182
3183        assert_eq!(children.len(), 2);
3184        assert_eq!(children[0].id, "111");
3185        assert_eq!(children[0].title, "Child One");
3186        assert_eq!(children[1].id, "222");
3187    }
3188
3189    #[tokio::test]
3190    async fn get_children_empty() {
3191        let server = wiremock::MockServer::start().await;
3192
3193        wiremock::Mock::given(wiremock::matchers::method("GET"))
3194            .and(wiremock::matchers::path(
3195                "/wiki/rest/api/content/12345/child/page",
3196            ))
3197            .respond_with(
3198                wiremock::ResponseTemplate::new(200)
3199                    .set_body_json(serde_json::json!({"results": []})),
3200            )
3201            .expect(1)
3202            .mount(&server)
3203            .await;
3204
3205        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3206        let api = ConfluenceApi::new(client);
3207        let children = api.get_children("12345").await.unwrap();
3208        assert!(children.is_empty());
3209    }
3210
3211    #[tokio::test]
3212    async fn get_children_pagination() {
3213        let server = wiremock::MockServer::start().await;
3214
3215        wiremock::Mock::given(wiremock::matchers::method("GET"))
3216            .and(wiremock::matchers::path(
3217                "/wiki/rest/api/content/12345/child/page",
3218            ))
3219            .and(wiremock::matchers::query_param_is_missing("start"))
3220            .respond_with(
3221                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3222                    "results": [{"id": "111", "title": "First", "status": "current"}],
3223                    "_links": {
3224                        "next": "/wiki/rest/api/content/12345/child/page?limit=50&start=50"
3225                    }
3226                })),
3227            )
3228            .expect(1)
3229            .mount(&server)
3230            .await;
3231
3232        wiremock::Mock::given(wiremock::matchers::method("GET"))
3233            .and(wiremock::matchers::path(
3234                "/wiki/rest/api/content/12345/child/page",
3235            ))
3236            .and(wiremock::matchers::query_param("start", "50"))
3237            .respond_with(
3238                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3239                    "results": [{"id": "222", "title": "Second", "status": "current"}]
3240                })),
3241            )
3242            .expect(1)
3243            .mount(&server)
3244            .await;
3245
3246        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3247        let api = ConfluenceApi::new(client);
3248        let children = api.get_children("12345").await.unwrap();
3249        assert_eq!(children.len(), 2);
3250        assert_eq!(children[0].status, "current");
3251        assert_eq!(children[0].parent_id.as_deref(), Some("12345"));
3252    }
3253
3254    #[tokio::test]
3255    async fn get_space_root_pages_success() {
3256        let server = wiremock::MockServer::start().await;
3257
3258        wiremock::Mock::given(wiremock::matchers::method("GET"))
3259            .and(wiremock::matchers::path("/wiki/api/v2/spaces/98765/pages"))
3260            .and(wiremock::matchers::query_param("depth", "root"))
3261            .respond_with(
3262                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3263                    "results": [
3264                        {"id": "111", "title": "Top One", "status": "current"},
3265                        {"id": "222", "title": "Top Two", "status": "draft", "parentId": null}
3266                    ]
3267                })),
3268            )
3269            .expect(1)
3270            .mount(&server)
3271            .await;
3272
3273        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3274        let api = ConfluenceApi::new(client);
3275        let pages = api.get_space_root_pages("98765").await.unwrap();
3276        assert_eq!(pages.len(), 2);
3277        assert_eq!(pages[0].id, "111");
3278        assert_eq!(pages[0].status, "current");
3279        assert_eq!(pages[1].status, "draft");
3280    }
3281
3282    #[tokio::test]
3283    async fn get_space_root_pages_empty() {
3284        let server = wiremock::MockServer::start().await;
3285
3286        wiremock::Mock::given(wiremock::matchers::method("GET"))
3287            .and(wiremock::matchers::path("/wiki/api/v2/spaces/98765/pages"))
3288            .respond_with(
3289                wiremock::ResponseTemplate::new(200)
3290                    .set_body_json(serde_json::json!({"results": []})),
3291            )
3292            .expect(1)
3293            .mount(&server)
3294            .await;
3295
3296        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3297        let api = ConfluenceApi::new(client);
3298        let pages = api.get_space_root_pages("98765").await.unwrap();
3299        assert!(pages.is_empty());
3300    }
3301
3302    #[tokio::test]
3303    async fn get_space_root_pages_api_error() {
3304        let server = wiremock::MockServer::start().await;
3305
3306        wiremock::Mock::given(wiremock::matchers::method("GET"))
3307            .and(wiremock::matchers::path("/wiki/api/v2/spaces/99999/pages"))
3308            .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
3309            .expect(1)
3310            .mount(&server)
3311            .await;
3312
3313        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3314        let api = ConfluenceApi::new(client);
3315        let err = api.get_space_root_pages("99999").await.unwrap_err();
3316        assert!(err.to_string().contains("403"));
3317    }
3318
3319    #[tokio::test]
3320    async fn get_space_root_pages_pagination() {
3321        let server = wiremock::MockServer::start().await;
3322
3323        wiremock::Mock::given(wiremock::matchers::method("GET"))
3324            .and(wiremock::matchers::path("/wiki/api/v2/spaces/98765/pages"))
3325            .and(wiremock::matchers::query_param_is_missing("cursor"))
3326            .respond_with(
3327                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3328                    "results": [{"id": "111", "title": "A", "status": "current"}],
3329                    "_links": {
3330                        "next": "/wiki/api/v2/spaces/98765/pages?depth=root&cursor=page2"
3331                    }
3332                })),
3333            )
3334            .expect(1)
3335            .mount(&server)
3336            .await;
3337
3338        wiremock::Mock::given(wiremock::matchers::method("GET"))
3339            .and(wiremock::matchers::path("/wiki/api/v2/spaces/98765/pages"))
3340            .and(wiremock::matchers::query_param("cursor", "page2"))
3341            .respond_with(
3342                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3343                    "results": [{"id": "222", "title": "B", "status": "current"}]
3344                })),
3345            )
3346            .expect(1)
3347            .mount(&server)
3348            .await;
3349
3350        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3351        let api = ConfluenceApi::new(client);
3352        let pages = api.get_space_root_pages("98765").await.unwrap();
3353        assert_eq!(pages.len(), 2);
3354        assert_eq!(pages[0].id, "111");
3355        assert_eq!(pages[1].id, "222");
3356    }
3357
3358    #[tokio::test]
3359    async fn get_children_api_error() {
3360        let server = wiremock::MockServer::start().await;
3361
3362        wiremock::Mock::given(wiremock::matchers::method("GET"))
3363            .and(wiremock::matchers::path(
3364                "/wiki/rest/api/content/99999/child/page",
3365            ))
3366            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
3367            .expect(1)
3368            .mount(&server)
3369            .await;
3370
3371        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3372        let api = ConfluenceApi::new(client);
3373        let err = api.get_children("99999").await.unwrap_err();
3374        assert!(err.to_string().contains("404"));
3375    }
3376
3377    #[tokio::test]
3378    async fn resolve_space_key_fallback_on_error() {
3379        let server = wiremock::MockServer::start().await;
3380
3381        wiremock::Mock::given(wiremock::matchers::method("GET"))
3382            .and(wiremock::matchers::path("/wiki/api/v2/spaces/unknown"))
3383            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
3384            .mount(&server)
3385            .await;
3386
3387        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3388        let api = ConfluenceApi::new(client);
3389        let key = api.resolve_space_key("unknown").await.unwrap();
3390        // Falls back to the space ID when lookup fails
3391        assert_eq!(key, "unknown");
3392    }
3393
3394    // ── get_page_comments ─────────────────────────────────────────
3395
3396    #[tokio::test]
3397    async fn get_page_comments_success() {
3398        let server = wiremock::MockServer::start().await;
3399
3400        wiremock::Mock::given(wiremock::matchers::method("GET"))
3401            .and(wiremock::matchers::path(
3402                "/wiki/api/v2/pages/12345/footer-comments",
3403            ))
3404            .respond_with(
3405                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3406                    "results": [
3407                        {
3408                            "id": "100",
3409                            "version": {
3410                                "authorId": "user-abc",
3411                                "createdAt": "2026-04-01T10:00:00.000Z"
3412                            },
3413                            "body": {
3414                                "atlas_doc_format": {
3415                                    "value": "{\"version\":1,\"type\":\"doc\",\"content\":[]}"
3416                                }
3417                            }
3418                        },
3419                        {
3420                            "id": "101",
3421                            "version": {
3422                                "authorId": "user-def",
3423                                "createdAt": "2026-04-02T14:00:00.000Z"
3424                            }
3425                        }
3426                    ]
3427                })),
3428            )
3429            .expect(1)
3430            .mount(&server)
3431            .await;
3432
3433        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3434        let api = ConfluenceApi::new(client);
3435        let comments = api.get_page_comments("12345").await.unwrap();
3436
3437        assert_eq!(comments.len(), 2);
3438        assert_eq!(comments[0].id, "100");
3439        assert_eq!(comments[0].author, "user-abc");
3440        assert!(comments[0].body_adf.is_some());
3441        assert_eq!(comments[1].id, "101");
3442        assert!(comments[1].body_adf.is_none());
3443    }
3444
3445    #[tokio::test]
3446    async fn get_page_comments_malformed_adf_body() {
3447        let server = wiremock::MockServer::start().await;
3448
3449        wiremock::Mock::given(wiremock::matchers::method("GET"))
3450            .and(wiremock::matchers::path(
3451                "/wiki/api/v2/pages/12345/footer-comments",
3452            ))
3453            .respond_with(
3454                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3455                    "results": [
3456                        {
3457                            "id": "100",
3458                            "version": {
3459                                "authorId": "user-abc",
3460                                "createdAt": "2026-04-01T10:00:00.000Z"
3461                            },
3462                            "body": {
3463                                "atlas_doc_format": {
3464                                    "value": "{ invalid json }"
3465                                }
3466                            }
3467                        }
3468                    ]
3469                })),
3470            )
3471            .expect(1)
3472            .mount(&server)
3473            .await;
3474
3475        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3476        let api = ConfluenceApi::new(client);
3477        let comments = api.get_page_comments("12345").await.unwrap();
3478
3479        assert_eq!(comments.len(), 1);
3480        assert_eq!(comments[0].id, "100");
3481        // Malformed ADF silently becomes None
3482        assert!(comments[0].body_adf.is_none());
3483    }
3484
3485    #[tokio::test]
3486    async fn get_page_comments_missing_version() {
3487        let server = wiremock::MockServer::start().await;
3488
3489        wiremock::Mock::given(wiremock::matchers::method("GET"))
3490            .and(wiremock::matchers::path(
3491                "/wiki/api/v2/pages/12345/footer-comments",
3492            ))
3493            .respond_with(
3494                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3495                    "results": [
3496                        {
3497                            "id": "100"
3498                        }
3499                    ]
3500                })),
3501            )
3502            .expect(1)
3503            .mount(&server)
3504            .await;
3505
3506        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3507        let api = ConfluenceApi::new(client);
3508        let comments = api.get_page_comments("12345").await.unwrap();
3509
3510        assert_eq!(comments.len(), 1);
3511        assert_eq!(comments[0].author, "");
3512        assert_eq!(comments[0].created, "");
3513        assert!(comments[0].body_adf.is_none());
3514    }
3515
3516    #[tokio::test]
3517    async fn get_page_comments_empty() {
3518        let server = wiremock::MockServer::start().await;
3519
3520        wiremock::Mock::given(wiremock::matchers::method("GET"))
3521            .and(wiremock::matchers::path(
3522                "/wiki/api/v2/pages/12345/footer-comments",
3523            ))
3524            .respond_with(
3525                wiremock::ResponseTemplate::new(200)
3526                    .set_body_json(serde_json::json!({"results": []})),
3527            )
3528            .expect(1)
3529            .mount(&server)
3530            .await;
3531
3532        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3533        let api = ConfluenceApi::new(client);
3534        let comments = api.get_page_comments("12345").await.unwrap();
3535        assert!(comments.is_empty());
3536    }
3537
3538    #[tokio::test]
3539    async fn get_page_comments_api_error() {
3540        let server = wiremock::MockServer::start().await;
3541
3542        wiremock::Mock::given(wiremock::matchers::method("GET"))
3543            .and(wiremock::matchers::path(
3544                "/wiki/api/v2/pages/99999/footer-comments",
3545            ))
3546            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
3547            .expect(1)
3548            .mount(&server)
3549            .await;
3550
3551        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3552        let api = ConfluenceApi::new(client);
3553        let err = api.get_page_comments("99999").await.unwrap_err();
3554        assert!(err.to_string().contains("404"));
3555    }
3556
3557    #[tokio::test]
3558    async fn get_page_comments_with_pagination() {
3559        let server = wiremock::MockServer::start().await;
3560
3561        // First page returns one comment with a next link.
3562        wiremock::Mock::given(wiremock::matchers::method("GET"))
3563            .and(wiremock::matchers::path(
3564                "/wiki/api/v2/pages/12345/footer-comments",
3565            ))
3566            .and(wiremock::matchers::query_param_is_missing("cursor"))
3567            .respond_with(
3568                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3569                    "results": [
3570                        {
3571                            "id": "100",
3572                            "version": {
3573                                "authorId": "user-abc",
3574                                "createdAt": "2026-04-01T10:00:00.000Z"
3575                            },
3576                            "body": {
3577                                "atlas_doc_format": {
3578                                    "value": "{\"version\":1,\"type\":\"doc\",\"content\":[]}"
3579                                }
3580                            }
3581                        }
3582                    ],
3583                    "_links": {
3584                        "next": "/wiki/api/v2/pages/12345/footer-comments?body-format=atlas_doc_format&cursor=page2"
3585                    }
3586                })),
3587            )
3588            .expect(1)
3589            .mount(&server)
3590            .await;
3591
3592        // Second page returns another comment with no next link.
3593        wiremock::Mock::given(wiremock::matchers::method("GET"))
3594            .and(wiremock::matchers::path(
3595                "/wiki/api/v2/pages/12345/footer-comments",
3596            ))
3597            .and(wiremock::matchers::query_param("cursor", "page2"))
3598            .respond_with(
3599                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3600                    "results": [
3601                        {
3602                            "id": "101",
3603                            "version": {
3604                                "authorId": "user-def",
3605                                "createdAt": "2026-04-02T14:00:00.000Z"
3606                            }
3607                        }
3608                    ]
3609                })),
3610            )
3611            .expect(1)
3612            .mount(&server)
3613            .await;
3614
3615        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3616        let api = ConfluenceApi::new(client);
3617        let comments = api.get_page_comments("12345").await.unwrap();
3618
3619        assert_eq!(comments.len(), 2);
3620        assert_eq!(comments[0].id, "100");
3621        assert_eq!(comments[0].author, "user-abc");
3622        assert!(comments[0].body_adf.is_some());
3623        assert_eq!(comments[1].id, "101");
3624        assert_eq!(comments[1].author, "user-def");
3625    }
3626
3627    #[tokio::test]
3628    async fn get_page_comments_pagination_stops_on_empty_page() {
3629        let server = wiremock::MockServer::start().await;
3630
3631        // Response advertises a next link but returns no results; loop must stop
3632        // to avoid infinite pagination.
3633        wiremock::Mock::given(wiremock::matchers::method("GET"))
3634            .and(wiremock::matchers::path(
3635                "/wiki/api/v2/pages/12345/footer-comments",
3636            ))
3637            .respond_with(
3638                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3639                    "results": [],
3640                    "_links": {
3641                        "next": "/wiki/api/v2/pages/12345/footer-comments?cursor=loop"
3642                    }
3643                })),
3644            )
3645            .expect(1)
3646            .mount(&server)
3647            .await;
3648
3649        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3650        let api = ConfluenceApi::new(client);
3651        let comments = api.get_page_comments("12345").await.unwrap();
3652        assert!(comments.is_empty());
3653    }
3654
3655    // ── add_page_comment ──────────────────────────────────────────
3656
3657    #[tokio::test]
3658    async fn add_page_comment_success() {
3659        let server = wiremock::MockServer::start().await;
3660
3661        wiremock::Mock::given(wiremock::matchers::method("POST"))
3662            .and(wiremock::matchers::path("/wiki/api/v2/footer-comments"))
3663            .respond_with(
3664                wiremock::ResponseTemplate::new(200)
3665                    .set_body_json(serde_json::json!({"id": "200"})),
3666            )
3667            .expect(1)
3668            .mount(&server)
3669            .await;
3670
3671        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3672        let api = ConfluenceApi::new(client);
3673        let adf = ValidatedAdfDocument::empty();
3674        let result = api.add_page_comment("12345", &adf).await;
3675        assert!(result.is_ok());
3676    }
3677
3678    #[tokio::test]
3679    async fn add_page_comment_api_error() {
3680        let server = wiremock::MockServer::start().await;
3681
3682        wiremock::Mock::given(wiremock::matchers::method("POST"))
3683            .and(wiremock::matchers::path("/wiki/api/v2/footer-comments"))
3684            .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
3685            .expect(1)
3686            .mount(&server)
3687            .await;
3688
3689        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3690        let api = ConfluenceApi::new(client);
3691        let adf = ValidatedAdfDocument::empty();
3692        let err = api.add_page_comment("12345", &adf).await.unwrap_err();
3693        assert!(err.to_string().contains("403"));
3694    }
3695
3696    #[tokio::test]
3697    async fn add_page_comment_500_with_panel_expand_diagnoses() {
3698        let server = wiremock::MockServer::start().await;
3699
3700        wiremock::Mock::given(wiremock::matchers::method("POST"))
3701            .and(wiremock::matchers::path("/wiki/api/v2/footer-comments"))
3702            .respond_with(
3703                wiremock::ResponseTemplate::new(500).set_body_string("Internal Server Error"),
3704            )
3705            .expect(1)
3706            .mount(&server)
3707            .await;
3708
3709        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3710        let api = ConfluenceApi::new(client);
3711        let adf = ValidatedAdfDocument::trust(adf_with_panel_expand());
3712        let err = api.add_page_comment("12345", &adf).await.unwrap_err();
3713        let msg = err.to_string();
3714        assert!(msg.contains("HTTP 500"), "missing status in: {msg}");
3715        assert!(
3716            msg.contains("Diagnosis:"),
3717            "missing Diagnosis line in: {msg}"
3718        );
3719        assert!(msg.contains("`expand`"), "missing child name in: {msg}");
3720        assert!(msg.contains("`panel`"), "missing parent name in: {msg}");
3721        assert!(msg.contains("Hint:"), "missing Hint line in: {msg}");
3722    }
3723
3724    // ── get_labels ────────────────────────────────────────────────
3725
3726    #[tokio::test]
3727    async fn get_labels_success() {
3728        let server = wiremock::MockServer::start().await;
3729
3730        wiremock::Mock::given(wiremock::matchers::method("GET"))
3731            .and(wiremock::matchers::path("/wiki/api/v2/pages/12345/labels"))
3732            .respond_with(
3733                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3734                    "results": [
3735                        {"id": "1", "name": "architecture", "prefix": "global"},
3736                        {"id": "2", "name": "draft", "prefix": "global"}
3737                    ]
3738                })),
3739            )
3740            .expect(1)
3741            .mount(&server)
3742            .await;
3743
3744        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3745        let api = ConfluenceApi::new(client);
3746        let labels = api.get_labels("12345").await.unwrap();
3747
3748        assert_eq!(labels.len(), 2);
3749        assert_eq!(labels[0].name, "architecture");
3750        assert_eq!(labels[0].prefix, "global");
3751        assert_eq!(labels[1].name, "draft");
3752    }
3753
3754    #[tokio::test]
3755    async fn get_labels_with_pagination() {
3756        let server = wiremock::MockServer::start().await;
3757
3758        // First page returns one label with a next link.
3759        wiremock::Mock::given(wiremock::matchers::method("GET"))
3760            .and(wiremock::matchers::path("/wiki/api/v2/pages/12345/labels"))
3761            .and(wiremock::matchers::query_param_is_missing("cursor"))
3762            .respond_with(
3763                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3764                    "results": [
3765                        {"id": "1", "name": "architecture", "prefix": "global"}
3766                    ],
3767                    "_links": {
3768                        "next": "/wiki/api/v2/pages/12345/labels?cursor=page2"
3769                    }
3770                })),
3771            )
3772            .expect(1)
3773            .mount(&server)
3774            .await;
3775
3776        // Second page returns another label with no next link.
3777        wiremock::Mock::given(wiremock::matchers::method("GET"))
3778            .and(wiremock::matchers::path("/wiki/api/v2/pages/12345/labels"))
3779            .and(wiremock::matchers::query_param("cursor", "page2"))
3780            .respond_with(
3781                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3782                    "results": [
3783                        {"id": "2", "name": "draft", "prefix": "global"}
3784                    ]
3785                })),
3786            )
3787            .expect(1)
3788            .mount(&server)
3789            .await;
3790
3791        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3792        let api = ConfluenceApi::new(client);
3793        let labels = api.get_labels("12345").await.unwrap();
3794
3795        assert_eq!(labels.len(), 2);
3796        assert_eq!(labels[0].name, "architecture");
3797        assert_eq!(labels[1].name, "draft");
3798    }
3799
3800    #[tokio::test]
3801    async fn get_labels_empty() {
3802        let server = wiremock::MockServer::start().await;
3803
3804        wiremock::Mock::given(wiremock::matchers::method("GET"))
3805            .and(wiremock::matchers::path("/wiki/api/v2/pages/12345/labels"))
3806            .respond_with(
3807                wiremock::ResponseTemplate::new(200)
3808                    .set_body_json(serde_json::json!({"results": []})),
3809            )
3810            .expect(1)
3811            .mount(&server)
3812            .await;
3813
3814        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3815        let api = ConfluenceApi::new(client);
3816        let labels = api.get_labels("12345").await.unwrap();
3817        assert!(labels.is_empty());
3818    }
3819
3820    #[tokio::test]
3821    async fn get_labels_api_error() {
3822        let server = wiremock::MockServer::start().await;
3823
3824        wiremock::Mock::given(wiremock::matchers::method("GET"))
3825            .and(wiremock::matchers::path("/wiki/api/v2/pages/99999/labels"))
3826            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
3827            .expect(1)
3828            .mount(&server)
3829            .await;
3830
3831        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3832        let api = ConfluenceApi::new(client);
3833        let err = api.get_labels("99999").await.unwrap_err();
3834        assert!(err.to_string().contains("404"));
3835    }
3836
3837    // ── add_labels ────────────────────────────────────────────────
3838
3839    #[tokio::test]
3840    async fn add_labels_success() {
3841        let server = wiremock::MockServer::start().await;
3842
3843        wiremock::Mock::given(wiremock::matchers::method("POST"))
3844            .and(wiremock::matchers::path(
3845                "/wiki/rest/api/content/12345/label",
3846            ))
3847            .respond_with(
3848                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3849                    "results": [
3850                        {"prefix": "global", "name": "architecture", "id": "1"},
3851                        {"prefix": "global", "name": "draft", "id": "2"}
3852                    ]
3853                })),
3854            )
3855            .expect(1)
3856            .mount(&server)
3857            .await;
3858
3859        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3860        let api = ConfluenceApi::new(client);
3861        let result = api
3862            .add_labels("12345", &["architecture".to_string(), "draft".to_string()])
3863            .await;
3864        assert!(result.is_ok());
3865    }
3866
3867    #[tokio::test]
3868    async fn add_labels_api_error() {
3869        let server = wiremock::MockServer::start().await;
3870
3871        wiremock::Mock::given(wiremock::matchers::method("POST"))
3872            .and(wiremock::matchers::path(
3873                "/wiki/rest/api/content/99999/label",
3874            ))
3875            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
3876            .expect(1)
3877            .mount(&server)
3878            .await;
3879
3880        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3881        let api = ConfluenceApi::new(client);
3882        let err = api
3883            .add_labels("99999", &["test".to_string()])
3884            .await
3885            .unwrap_err();
3886        assert!(err.to_string().contains("404"));
3887    }
3888
3889    // ── remove_label ──────────────────────────────────────────────
3890
3891    #[tokio::test]
3892    async fn remove_label_success() {
3893        let server = wiremock::MockServer::start().await;
3894
3895        wiremock::Mock::given(wiremock::matchers::method("DELETE"))
3896            .and(wiremock::matchers::path(
3897                "/wiki/rest/api/content/12345/label/architecture",
3898            ))
3899            .respond_with(wiremock::ResponseTemplate::new(204))
3900            .expect(1)
3901            .mount(&server)
3902            .await;
3903
3904        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3905        let api = ConfluenceApi::new(client);
3906        let result = api.remove_label("12345", "architecture").await;
3907        assert!(result.is_ok());
3908    }
3909
3910    #[tokio::test]
3911    async fn remove_label_api_error() {
3912        let server = wiremock::MockServer::start().await;
3913
3914        wiremock::Mock::given(wiremock::matchers::method("DELETE"))
3915            .and(wiremock::matchers::path(
3916                "/wiki/rest/api/content/99999/label/missing",
3917            ))
3918            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
3919            .expect(1)
3920            .mount(&server)
3921            .await;
3922
3923        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3924        let api = ConfluenceApi::new(client);
3925        let err = api.remove_label("99999", "missing").await.unwrap_err();
3926        assert!(err.to_string().contains("404"));
3927    }
3928
3929    // ── label struct serialization ────────────────────────────────
3930
3931    #[test]
3932    fn confluence_label_entry_deserialization() {
3933        let json = r#"{"id": "1", "name": "architecture", "prefix": "global"}"#;
3934        let entry: ConfluenceLabelEntry = serde_json::from_str(json).unwrap();
3935        assert_eq!(entry.id, "1");
3936        assert_eq!(entry.name, "architecture");
3937        assert_eq!(entry.prefix, "global");
3938    }
3939
3940    #[test]
3941    fn confluence_add_label_entry_serialization() {
3942        let entry = ConfluenceAddLabelEntry {
3943            prefix: "global".to_string(),
3944            name: "test".to_string(),
3945        };
3946        let json = serde_json::to_value(&entry).unwrap();
3947        assert_eq!(json["prefix"], "global");
3948        assert_eq!(json["name"], "test");
3949    }
3950
3951    // ── SinceFilter::parse ────────────────────────────────────────
3952
3953    #[test]
3954    fn since_filter_parse_numeric() {
3955        assert_eq!(SinceFilter::parse("5").unwrap(), SinceFilter::Version(5));
3956        assert_eq!(SinceFilter::parse("0").unwrap(), SinceFilter::Version(0));
3957    }
3958
3959    #[test]
3960    fn since_filter_parse_iso_date() {
3961        let f = SinceFilter::parse("2026-01-01T00:00:00Z").unwrap();
3962        assert_eq!(
3963            f,
3964            SinceFilter::CreatedAt("2026-01-01T00:00:00Z".to_string())
3965        );
3966    }
3967
3968    // ── attachments ───────────────────────────────────────────────
3969
3970    #[test]
3971    fn extract_cursor_extracts_value() {
3972        let next = "/wiki/api/v2/pages/12345/attachments?cursor=abc123&limit=25";
3973        assert_eq!(extract_cursor_from_next(next), Some("abc123".to_string()));
3974    }
3975
3976    #[test]
3977    fn extract_cursor_returns_none_when_absent() {
3978        assert_eq!(
3979            extract_cursor_from_next("/wiki/api/v2/pages/12345/attachments?limit=25"),
3980            None
3981        );
3982    }
3983
3984    #[test]
3985    fn since_filter_parse_iso_date_no_time() {
3986        let f = SinceFilter::parse("2026-01-01").unwrap();
3987        assert_eq!(f, SinceFilter::CreatedAt("2026-01-01".to_string()));
3988    }
3989
3990    #[test]
3991    fn since_filter_parse_trims_whitespace() {
3992        assert_eq!(
3993            SinceFilter::parse("  7  ").unwrap(),
3994            SinceFilter::Version(7)
3995        );
3996    }
3997
3998    #[test]
3999    fn extract_cursor_decodes_percent_encoded() {
4000        assert_eq!(
4001            extract_cursor_from_next("/wiki/api/v2/pages/1/attachments?cursor=foo%3Dbar"),
4002            Some("foo=bar".to_string())
4003        );
4004    }
4005
4006    #[test]
4007    fn since_filter_parse_empty_rejected() {
4008        assert!(SinceFilter::parse("").is_err());
4009        assert!(SinceFilter::parse("   ").is_err());
4010    }
4011
4012    #[test]
4013    fn since_filter_parse_garbage_rejected() {
4014        assert!(SinceFilter::parse("nope").is_err());
4015    }
4016
4017    #[test]
4018    fn since_filter_matches_version() {
4019        let v = PageVersion {
4020            number: 5,
4021            created_at: String::new(),
4022            author_id: String::new(),
4023            message: String::new(),
4024            minor_edit: false,
4025        };
4026        assert!(SinceFilter::Version(5).matches(&v));
4027        assert!(SinceFilter::Version(4).matches(&v));
4028        assert!(!SinceFilter::Version(6).matches(&v));
4029    }
4030
4031    #[test]
4032    fn since_filter_matches_created_at() {
4033        let v = PageVersion {
4034            number: 1,
4035            created_at: "2026-05-01T00:00:00Z".to_string(),
4036            author_id: String::new(),
4037            message: String::new(),
4038            minor_edit: false,
4039        };
4040        assert!(SinceFilter::CreatedAt("2026-04-01T00:00:00Z".to_string()).matches(&v));
4041        assert!(SinceFilter::CreatedAt("2026-05-01T00:00:00Z".to_string()).matches(&v));
4042        assert!(!SinceFilter::CreatedAt("2026-06-01T00:00:00Z".to_string()).matches(&v));
4043    }
4044
4045    #[test]
4046    fn since_filter_created_at_treats_empty_as_too_old() {
4047        let v = PageVersion {
4048            number: 1,
4049            created_at: String::new(),
4050            author_id: String::new(),
4051            message: String::new(),
4052            minor_edit: false,
4053        };
4054        assert!(!SinceFilter::CreatedAt("2026-01-01".to_string()).matches(&v));
4055    }
4056
4057    // ── ConfluenceVersionEntry deserialization ────────────────────
4058
4059    #[test]
4060    fn version_entry_deserialization_full() {
4061        let json = r#"{
4062            "number": 9,
4063            "createdAt": "2026-05-08T10:23:11Z",
4064            "message": "Updated DB version",
4065            "minorEdit": false,
4066            "authorId": "abc-123"
4067        }"#;
4068        let entry: ConfluenceVersionEntry = serde_json::from_str(json).unwrap();
4069        assert_eq!(entry.number, 9);
4070        assert_eq!(entry.created_at.as_deref(), Some("2026-05-08T10:23:11Z"));
4071        assert_eq!(entry.message.as_deref(), Some("Updated DB version"));
4072        assert_eq!(entry.minor_edit, Some(false));
4073        assert_eq!(entry.author_id.as_deref(), Some("abc-123"));
4074    }
4075
4076    #[test]
4077    fn version_entry_deserialization_sparse() {
4078        let json = r#"{"number": 1}"#;
4079        let entry: ConfluenceVersionEntry = serde_json::from_str(json).unwrap();
4080        assert_eq!(entry.number, 1);
4081        assert!(entry.created_at.is_none());
4082        assert!(entry.message.is_none());
4083        assert!(entry.minor_edit.is_none());
4084        assert!(entry.author_id.is_none());
4085    }
4086
4087    // ── get_page_metadata ─────────────────────────────────────────
4088
4089    #[tokio::test]
4090    async fn get_page_metadata_success() {
4091        let server = wiremock::MockServer::start().await;
4092        wiremock::Mock::given(wiremock::matchers::method("GET"))
4093            .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
4094            .respond_with(
4095                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
4096                    "id": "12345",
4097                    "title": "Hello",
4098                    "status": "current",
4099                    "spaceId": "1",
4100                    "version": {"number": 7}
4101                })),
4102            )
4103            .mount(&server)
4104            .await;
4105
4106        let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
4107        let api = ConfluenceApi::new(client);
4108        let meta = api.get_page_metadata("12345").await.unwrap();
4109        assert_eq!(meta.id, "12345");
4110        assert_eq!(meta.title, "Hello");
4111        assert_eq!(meta.current_version, Some(7));
4112    }
4113
4114    #[tokio::test]
4115    async fn get_page_metadata_no_version() {
4116        let server = wiremock::MockServer::start().await;
4117        wiremock::Mock::given(wiremock::matchers::method("GET"))
4118            .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
4119            .respond_with(
4120                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
4121                    "id": "12345",
4122                    "title": "Hello",
4123                    "status": "current",
4124                    "spaceId": "1"
4125                })),
4126            )
4127            .mount(&server)
4128            .await;
4129
4130        let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
4131        let api = ConfluenceApi::new(client);
4132        let meta = api.get_page_metadata("12345").await.unwrap();
4133        assert_eq!(meta.current_version, None);
4134    }
4135
4136    #[tokio::test]
4137    async fn get_page_metadata_api_error() {
4138        let server = wiremock::MockServer::start().await;
4139        wiremock::Mock::given(wiremock::matchers::method("GET"))
4140            .and(wiremock::matchers::path("/wiki/api/v2/pages/99999"))
4141            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
4142            .mount(&server)
4143            .await;
4144
4145        let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
4146        let api = ConfluenceApi::new(client);
4147        let err = api.get_page_metadata("99999").await.unwrap_err();
4148        assert!(err.to_string().contains("404"));
4149    }
4150
4151    // ── list_page_versions ────────────────────────────────────────
4152
4153    fn version_json(
4154        number: u32,
4155        created: &str,
4156        author: &str,
4157        msg: &str,
4158        minor: bool,
4159    ) -> serde_json::Value {
4160        serde_json::json!({
4161            "number": number,
4162            "createdAt": created,
4163            "message": msg,
4164            "minorEdit": minor,
4165            "authorId": author
4166        })
4167    }
4168
4169    #[tokio::test]
4170    async fn list_page_versions_single_page() {
4171        let server = wiremock::MockServer::start().await;
4172        wiremock::Mock::given(wiremock::matchers::method("GET"))
4173            .and(wiremock::matchers::path("/wiki/api/v2/pages/12/versions"))
4174            .respond_with(
4175                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
4176                    "results": [
4177                        version_json(3, "2026-05-08T10:00:00Z", "a", "third", false),
4178                        version_json(2, "2026-05-07T10:00:00Z", "b", "second", true),
4179                        version_json(1, "2026-05-06T10:00:00Z", "c", "first", false),
4180                    ]
4181                })),
4182            )
4183            .mount(&server)
4184            .await;
4185
4186        let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
4187        let api = ConfluenceApi::new(client);
4188        let (versions, truncated) = api.list_page_versions("12", None, 0).await.unwrap();
4189        assert_eq!(versions.len(), 3);
4190        assert!(!truncated);
4191        assert_eq!(versions[0].number, 3);
4192        assert_eq!(versions[0].author_id, "a");
4193        assert_eq!(versions[0].message, "third");
4194        assert!(versions[1].minor_edit);
4195    }
4196
4197    #[tokio::test]
4198    async fn list_page_versions_paginates_until_exhausted() {
4199        let server = wiremock::MockServer::start().await;
4200        // First page advertises a `next` link; second page is the last.
4201        wiremock::Mock::given(wiremock::matchers::method("GET"))
4202            .and(wiremock::matchers::path("/wiki/api/v2/pages/12/versions"))
4203            .and(wiremock::matchers::query_param("limit", "100"))
4204            .respond_with(
4205                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
4206                    "results": [
4207                        version_json(4, "2026-05-08T10:00:00Z", "a", "four", false),
4208                        version_json(3, "2026-05-07T10:00:00Z", "b", "three", false),
4209                    ],
4210                    "_links": {"next": "/wiki/api/v2/pages/12/versions?cursor=abc"}
4211                })),
4212            )
4213            .mount(&server)
4214            .await;
4215
4216        wiremock::Mock::given(wiremock::matchers::method("GET"))
4217            .and(wiremock::matchers::path("/wiki/api/v2/pages/12/versions"))
4218            .and(wiremock::matchers::query_param("cursor", "abc"))
4219            .respond_with(
4220                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
4221                    "results": [
4222                        version_json(2, "2026-05-06T10:00:00Z", "c", "two", false),
4223                        version_json(1, "2026-05-05T10:00:00Z", "d", "one", false),
4224                    ]
4225                })),
4226            )
4227            .mount(&server)
4228            .await;
4229
4230        let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
4231        let api = ConfluenceApi::new(client);
4232        let (versions, truncated) = api.list_page_versions("12", None, 0).await.unwrap();
4233        assert_eq!(
4234            versions.iter().map(|v| v.number).collect::<Vec<_>>(),
4235            vec![4, 3, 2, 1]
4236        );
4237        assert!(!truncated);
4238    }
4239
4240    #[tokio::test]
4241    async fn list_page_versions_limit_truncates() {
4242        let server = wiremock::MockServer::start().await;
4243        wiremock::Mock::given(wiremock::matchers::method("GET"))
4244            .and(wiremock::matchers::path("/wiki/api/v2/pages/12/versions"))
4245            .respond_with(
4246                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
4247                    "results": [
4248                        version_json(5, "2026-05-09T10:00:00Z", "a", "five", false),
4249                        version_json(4, "2026-05-08T10:00:00Z", "b", "four", false),
4250                        version_json(3, "2026-05-07T10:00:00Z", "c", "three", false),
4251                    ]
4252                })),
4253            )
4254            .mount(&server)
4255            .await;
4256
4257        let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
4258        let api = ConfluenceApi::new(client);
4259        let (versions, truncated) = api.list_page_versions("12", None, 2).await.unwrap();
4260        assert_eq!(versions.len(), 2);
4261        assert!(truncated, "limit reached mid-page should mark truncated");
4262        assert_eq!(versions[0].number, 5);
4263        assert_eq!(versions[1].number, 4);
4264    }
4265
4266    #[tokio::test]
4267    async fn list_page_versions_limit_exact_page_with_next_truncated() {
4268        let server = wiremock::MockServer::start().await;
4269        wiremock::Mock::given(wiremock::matchers::method("GET"))
4270            .and(wiremock::matchers::path("/wiki/api/v2/pages/12/versions"))
4271            .respond_with(
4272                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
4273                    "results": [
4274                        version_json(5, "2026-05-09T10:00:00Z", "a", "five", false),
4275                        version_json(4, "2026-05-08T10:00:00Z", "b", "four", false),
4276                    ],
4277                    "_links": {"next": "/wiki/api/v2/pages/12/versions?cursor=z"}
4278                })),
4279            )
4280            .mount(&server)
4281            .await;
4282
4283        let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
4284        let api = ConfluenceApi::new(client);
4285        let (versions, truncated) = api.list_page_versions("12", None, 2).await.unwrap();
4286        assert_eq!(versions.len(), 2);
4287        assert!(truncated);
4288    }
4289
4290    #[tokio::test]
4291    async fn list_page_versions_since_numeric_filter() {
4292        let server = wiremock::MockServer::start().await;
4293        wiremock::Mock::given(wiremock::matchers::method("GET"))
4294            .and(wiremock::matchers::path("/wiki/api/v2/pages/12/versions"))
4295            .respond_with(
4296                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
4297                    "results": [
4298                        version_json(5, "2026-05-09T10:00:00Z", "a", "5", false),
4299                        version_json(4, "2026-05-08T10:00:00Z", "b", "4", false),
4300                        version_json(3, "2026-05-07T10:00:00Z", "c", "3", false),
4301                        version_json(2, "2026-05-06T10:00:00Z", "d", "2", false),
4302                    ]
4303                })),
4304            )
4305            .mount(&server)
4306            .await;
4307
4308        let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
4309        let api = ConfluenceApi::new(client);
4310        let filter = SinceFilter::parse("4").unwrap();
4311        let (versions, truncated) = api
4312            .list_page_versions("12", Some(&filter), 0)
4313            .await
4314            .unwrap();
4315        assert_eq!(
4316            versions.iter().map(|v| v.number).collect::<Vec<_>>(),
4317            vec![5, 4]
4318        );
4319        assert!(!truncated);
4320    }
4321
4322    #[tokio::test]
4323    async fn list_page_versions_since_iso_filter() {
4324        let server = wiremock::MockServer::start().await;
4325        wiremock::Mock::given(wiremock::matchers::method("GET"))
4326            .and(wiremock::matchers::path("/wiki/api/v2/pages/12/versions"))
4327            .respond_with(
4328                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
4329                    "results": [
4330                        version_json(3, "2026-05-08T10:00:00Z", "a", "", false),
4331                        version_json(2, "2026-04-01T10:00:00Z", "b", "", false),
4332                        version_json(1, "2026-03-01T10:00:00Z", "c", "", false),
4333                    ]
4334                })),
4335            )
4336            .mount(&server)
4337            .await;
4338
4339        let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
4340        let api = ConfluenceApi::new(client);
4341        let filter = SinceFilter::parse("2026-05-01").unwrap();
4342        let (versions, truncated) = api
4343            .list_page_versions("12", Some(&filter), 0)
4344            .await
4345            .unwrap();
4346        assert_eq!(
4347            versions.iter().map(|v| v.number).collect::<Vec<_>>(),
4348            vec![3]
4349        );
4350        assert!(!truncated);
4351    }
4352
4353    #[tokio::test]
4354    async fn list_page_versions_since_stops_pagination_early() {
4355        let server = wiremock::MockServer::start().await;
4356        // Page 1 has results that all match; page 2 includes the cutoff version.
4357        wiremock::Mock::given(wiremock::matchers::method("GET"))
4358            .and(wiremock::matchers::path("/wiki/api/v2/pages/12/versions"))
4359            .and(wiremock::matchers::query_param("limit", "100"))
4360            .respond_with(
4361                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
4362                    "results": [
4363                        version_json(5, "2026-05-09T10:00:00Z", "a", "5", false),
4364                        version_json(4, "2026-05-08T10:00:00Z", "b", "4", false),
4365                    ],
4366                    "_links": {"next": "/wiki/api/v2/pages/12/versions?cursor=p2"}
4367                })),
4368            )
4369            .mount(&server)
4370            .await;
4371
4372        wiremock::Mock::given(wiremock::matchers::method("GET"))
4373            .and(wiremock::matchers::path("/wiki/api/v2/pages/12/versions"))
4374            .and(wiremock::matchers::query_param("cursor", "p2"))
4375            .respond_with(
4376                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
4377                    "results": [
4378                        version_json(3, "2026-05-07T10:00:00Z", "c", "3", false),
4379                        version_json(2, "2026-04-30T10:00:00Z", "d", "2", false),
4380                    ]
4381                })),
4382            )
4383            .mount(&server)
4384            .await;
4385
4386        let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
4387        let api = ConfluenceApi::new(client);
4388        let filter = SinceFilter::parse("2026-05-01").unwrap();
4389        let (versions, truncated) = api
4390            .list_page_versions("12", Some(&filter), 0)
4391            .await
4392            .unwrap();
4393        assert_eq!(
4394            versions.iter().map(|v| v.number).collect::<Vec<_>>(),
4395            vec![5, 4, 3]
4396        );
4397        assert!(!truncated, "since cutoff is a stop, not a truncation");
4398    }
4399
4400    #[tokio::test]
4401    async fn list_page_versions_tolerates_missing_optional_fields() {
4402        let server = wiremock::MockServer::start().await;
4403        wiremock::Mock::given(wiremock::matchers::method("GET"))
4404            .and(wiremock::matchers::path("/wiki/api/v2/pages/12/versions"))
4405            .respond_with(
4406                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
4407                    "results": [
4408                        {"number": 1}
4409                    ]
4410                })),
4411            )
4412            .mount(&server)
4413            .await;
4414
4415        let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
4416        let api = ConfluenceApi::new(client);
4417        let (versions, truncated) = api.list_page_versions("12", None, 0).await.unwrap();
4418        assert_eq!(versions.len(), 1);
4419        assert_eq!(versions[0].number, 1);
4420        assert_eq!(versions[0].created_at, "");
4421        assert_eq!(versions[0].author_id, "");
4422        assert_eq!(versions[0].message, "");
4423        assert!(!versions[0].minor_edit);
4424        assert!(!truncated);
4425    }
4426
4427    #[tokio::test]
4428    async fn list_page_versions_empty_result() {
4429        let server = wiremock::MockServer::start().await;
4430        wiremock::Mock::given(wiremock::matchers::method("GET"))
4431            .and(wiremock::matchers::path("/wiki/api/v2/pages/12/versions"))
4432            .respond_with(
4433                wiremock::ResponseTemplate::new(200)
4434                    .set_body_json(serde_json::json!({"results": []})),
4435            )
4436            .mount(&server)
4437            .await;
4438
4439        let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
4440        let api = ConfluenceApi::new(client);
4441        let (versions, truncated) = api.list_page_versions("12", None, 0).await.unwrap();
4442        assert!(versions.is_empty());
4443        assert!(!truncated);
4444    }
4445
4446    #[tokio::test]
4447    async fn list_page_versions_api_error() {
4448        let server = wiremock::MockServer::start().await;
4449        wiremock::Mock::given(wiremock::matchers::method("GET"))
4450            .and(wiremock::matchers::path(
4451                "/wiki/api/v2/pages/99999/versions",
4452            ))
4453            .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
4454            .mount(&server)
4455            .await;
4456
4457        let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
4458        let api = ConfluenceApi::new(client);
4459        let err = api.list_page_versions("99999", None, 0).await.unwrap_err();
4460        assert!(err.to_string().contains("403"));
4461    }
4462
4463    #[tokio::test]
4464    async fn list_page_versions_uses_limit_as_page_size_when_small() {
4465        let server = wiremock::MockServer::start().await;
4466        wiremock::Mock::given(wiremock::matchers::method("GET"))
4467            .and(wiremock::matchers::path("/wiki/api/v2/pages/12/versions"))
4468            .and(wiremock::matchers::query_param("limit", "5"))
4469            .respond_with(
4470                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
4471                    "results": [
4472                        version_json(1, "2026-05-01T00:00:00Z", "a", "", false),
4473                    ]
4474                })),
4475            )
4476            .mount(&server)
4477            .await;
4478
4479        let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
4480        let api = ConfluenceApi::new(client);
4481        let (versions, _) = api.list_page_versions("12", None, 5).await.unwrap();
4482        assert_eq!(versions.len(), 1);
4483    }
4484
4485    #[test]
4486    fn urlencoding_escapes_reserved_chars() {
4487        assert_eq!(urlencoding("a=b&c+d %e#"), "a%3Db%26c%2Bd%20%25e%23");
4488    }
4489
4490    #[tokio::test]
4491    async fn upload_attachment_success() {
4492        use tempfile::NamedTempFile;
4493        use tokio::io::AsyncWriteExt;
4494
4495        let server = wiremock::MockServer::start().await;
4496
4497        wiremock::Mock::given(wiremock::matchers::method("POST"))
4498            .and(wiremock::matchers::path(
4499                "/wiki/rest/api/content/12345/child/attachment",
4500            ))
4501            .and(wiremock::matchers::header("X-Atlassian-Token", "no-check"))
4502            .respond_with(
4503                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
4504                    "results": [{
4505                        "id": "att-1",
4506                        "title": "hello.txt",
4507                        "extensions": {
4508                            "mediaType": "text/plain",
4509                            "fileSize": 13,
4510                            "fileId": "f-1"
4511                        },
4512                        "version": {"number": 1},
4513                        "container": {"id": "12345"},
4514                        "_links": {"download": "/download/att-1"}
4515                    }]
4516                })),
4517            )
4518            .expect(1)
4519            .mount(&server)
4520            .await;
4521
4522        let mut tmp = tokio::fs::File::from_std(NamedTempFile::new().unwrap().into_file());
4523        tmp.write_all(b"hello, world!").await.unwrap();
4524        tmp.flush().await.unwrap();
4525
4526        // Re-create a path-backed temp file (NamedTempFile after into_file would be unlinked).
4527        let dir = tempfile::tempdir().unwrap();
4528        let path = dir.path().join("hello.txt");
4529        tokio::fs::write(&path, b"hello, world!").await.unwrap();
4530
4531        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4532        let api = ConfluenceApi::new(client);
4533        let attachment = api
4534            .upload_attachment("12345", &path, None, Some("v1"), false)
4535            .await
4536            .unwrap();
4537
4538        assert_eq!(attachment.id, "att-1");
4539        assert_eq!(attachment.title, "hello.txt");
4540        assert_eq!(attachment.media_type.as_deref(), Some("text/plain"));
4541        assert_eq!(attachment.file_size, Some(13));
4542        assert_eq!(attachment.version, Some(1));
4543        // The v1 response nests these under extensions/_links/container;
4544        // assert they are mapped, not silently dropped to None.
4545        assert_eq!(attachment.download_url.as_deref(), Some("/download/att-1"));
4546        assert_eq!(attachment.page_id.as_deref(), Some("12345"));
4547        assert_eq!(attachment.file_id.as_deref(), Some("f-1"));
4548    }
4549
4550    #[tokio::test]
4551    async fn upload_attachment_page_not_found() {
4552        let server = wiremock::MockServer::start().await;
4553
4554        wiremock::Mock::given(wiremock::matchers::method("POST"))
4555            .and(wiremock::matchers::path(
4556                "/wiki/rest/api/content/99999/child/attachment",
4557            ))
4558            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
4559            .expect(1)
4560            .mount(&server)
4561            .await;
4562
4563        let dir = tempfile::tempdir().unwrap();
4564        let path = dir.path().join("x.bin");
4565        tokio::fs::write(&path, b"x").await.unwrap();
4566
4567        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4568        let api = ConfluenceApi::new(client);
4569        let err = api
4570            .upload_attachment("99999", &path, None, None, false)
4571            .await
4572            .unwrap_err();
4573        assert!(err.to_string().contains("404"));
4574    }
4575
4576    #[tokio::test]
4577    async fn upload_attachment_too_large() {
4578        let server = wiremock::MockServer::start().await;
4579
4580        wiremock::Mock::given(wiremock::matchers::method("POST"))
4581            .and(wiremock::matchers::path(
4582                "/wiki/rest/api/content/12345/child/attachment",
4583            ))
4584            .respond_with(
4585                wiremock::ResponseTemplate::new(413).set_body_string("Request entity too large"),
4586            )
4587            .expect(1)
4588            .mount(&server)
4589            .await;
4590
4591        let dir = tempfile::tempdir().unwrap();
4592        let path = dir.path().join("big.bin");
4593        tokio::fs::write(&path, b"x").await.unwrap();
4594
4595        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4596        let api = ConfluenceApi::new(client);
4597        let err = api
4598            .upload_attachment("12345", &path, None, None, false)
4599            .await
4600            .unwrap_err();
4601        let msg = err.to_string();
4602        assert!(msg.contains("413"));
4603        assert!(msg.contains("Request entity too large"));
4604    }
4605
4606    #[tokio::test]
4607    async fn upload_attachment_overrides_filename() {
4608        let server = wiremock::MockServer::start().await;
4609
4610        wiremock::Mock::given(wiremock::matchers::method("POST"))
4611            .and(wiremock::matchers::path(
4612                "/wiki/rest/api/content/12345/child/attachment",
4613            ))
4614            .respond_with(
4615                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
4616                    "results": [{"id": "a", "title": "renamed.png"}]
4617                })),
4618            )
4619            .expect(1)
4620            .mount(&server)
4621            .await;
4622
4623        let dir = tempfile::tempdir().unwrap();
4624        let path = dir.path().join("source.bin");
4625        tokio::fs::write(&path, b"data").await.unwrap();
4626
4627        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4628        let api = ConfluenceApi::new(client);
4629        let attachment = api
4630            .upload_attachment("12345", &path, Some("renamed.png"), None, true)
4631            .await
4632            .unwrap();
4633        assert_eq!(attachment.title, "renamed.png");
4634    }
4635
4636    // Regression for #1005: the v2 attachments path has no POST handler and
4637    // returns HTTP 405. Mount it returning 405 *and* the correct v1 path
4638    // returning success; the upload must succeed, proving it targets v1.
4639    #[tokio::test]
4640    async fn upload_attachment_uses_v1_endpoint_not_v2() {
4641        let server = wiremock::MockServer::start().await;
4642
4643        wiremock::Mock::given(wiremock::matchers::method("POST"))
4644            .and(wiremock::matchers::path(
4645                "/wiki/api/v2/pages/12345/attachments",
4646            ))
4647            .respond_with(
4648                wiremock::ResponseTemplate::new(405)
4649                    .set_body_string(r#"{"errors":[{"status":405,"code":"METHOD_NOT_ALLOWED"}]}"#),
4650            )
4651            .mount(&server)
4652            .await;
4653        wiremock::Mock::given(wiremock::matchers::method("POST"))
4654            .and(wiremock::matchers::path(
4655                "/wiki/rest/api/content/12345/child/attachment",
4656            ))
4657            .respond_with(
4658                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
4659                    "results": [{"id": "att-9", "title": "ok.png"}]
4660                })),
4661            )
4662            .expect(1)
4663            .mount(&server)
4664            .await;
4665
4666        let dir = tempfile::tempdir().unwrap();
4667        let path = dir.path().join("ok.png");
4668        tokio::fs::write(&path, b"data").await.unwrap();
4669
4670        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4671        let api = ConfluenceApi::new(client);
4672        let attachment = api
4673            .upload_attachment("12345", &path, None, None, false)
4674            .await
4675            .unwrap();
4676        assert_eq!(attachment.id, "att-9");
4677    }
4678
4679    #[tokio::test]
4680    async fn list_attachments_success() {
4681        let server = wiremock::MockServer::start().await;
4682
4683        wiremock::Mock::given(wiremock::matchers::method("GET"))
4684            .and(wiremock::matchers::path(
4685                "/wiki/api/v2/pages/12345/attachments",
4686            ))
4687            .and(wiremock::matchers::query_param("limit", "25"))
4688            .respond_with(
4689                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
4690                    "results": [
4691                        {"id": "a1", "title": "one.png", "mediaType": "image/png", "fileSize": 100, "version": {"number": 1}},
4692                        {"id": "a2", "title": "two.pdf", "mediaType": "application/pdf"}
4693                    ],
4694                    "_links": {"next": "/wiki/api/v2/pages/12345/attachments?cursor=NEXT&limit=25"}
4695                })),
4696            )
4697            .expect(1)
4698            .mount(&server)
4699            .await;
4700
4701        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4702        let api = ConfluenceApi::new(client);
4703        let page = api.list_attachments("12345", None, 25).await.unwrap();
4704        assert_eq!(page.results.len(), 2);
4705        assert_eq!(page.results[0].id, "a1");
4706        assert_eq!(page.results[0].file_size, Some(100));
4707        assert_eq!(page.next_cursor.as_deref(), Some("NEXT"));
4708    }
4709
4710    #[tokio::test]
4711    async fn list_attachments_no_more_pages() {
4712        let server = wiremock::MockServer::start().await;
4713
4714        wiremock::Mock::given(wiremock::matchers::method("GET"))
4715            .and(wiremock::matchers::path(
4716                "/wiki/api/v2/pages/12345/attachments",
4717            ))
4718            .respond_with(
4719                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
4720                    "results": []
4721                })),
4722            )
4723            .expect(1)
4724            .mount(&server)
4725            .await;
4726
4727        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4728        let api = ConfluenceApi::new(client);
4729        let page = api.list_attachments("12345", None, 25).await.unwrap();
4730        assert!(page.results.is_empty());
4731        assert!(page.next_cursor.is_none());
4732    }
4733
4734    #[tokio::test]
4735    async fn list_attachments_page_not_found() {
4736        let server = wiremock::MockServer::start().await;
4737
4738        wiremock::Mock::given(wiremock::matchers::method("GET"))
4739            .and(wiremock::matchers::path(
4740                "/wiki/api/v2/pages/99999/attachments",
4741            ))
4742            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
4743            .expect(1)
4744            .mount(&server)
4745            .await;
4746
4747        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4748        let api = ConfluenceApi::new(client);
4749        let err = api.list_attachments("99999", None, 25).await.unwrap_err();
4750        assert!(err.to_string().contains("404"));
4751    }
4752
4753    #[tokio::test]
4754    async fn list_attachments_pagination_round_trip() {
4755        let server = wiremock::MockServer::start().await;
4756
4757        wiremock::Mock::given(wiremock::matchers::method("GET"))
4758            .and(wiremock::matchers::path(
4759                "/wiki/api/v2/pages/12345/attachments",
4760            ))
4761            .and(wiremock::matchers::query_param("cursor", "PAGE2"))
4762            .respond_with(
4763                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
4764                    "results": [{"id": "a3", "title": "three.bin"}]
4765                })),
4766            )
4767            .expect(1)
4768            .mount(&server)
4769            .await;
4770
4771        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4772        let api = ConfluenceApi::new(client);
4773        let page = api
4774            .list_attachments("12345", Some("PAGE2"), 25)
4775            .await
4776            .unwrap();
4777        assert_eq!(page.results.len(), 1);
4778        assert_eq!(page.results[0].id, "a3");
4779    }
4780
4781    #[tokio::test]
4782    async fn delete_attachment_success() {
4783        let server = wiremock::MockServer::start().await;
4784
4785        wiremock::Mock::given(wiremock::matchers::method("DELETE"))
4786            .and(wiremock::matchers::path("/wiki/api/v2/attachments/att-1"))
4787            .respond_with(wiremock::ResponseTemplate::new(204))
4788            .expect(1)
4789            .mount(&server)
4790            .await;
4791
4792        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4793        let api = ConfluenceApi::new(client);
4794        assert!(api.delete_attachment("att-1", false).await.is_ok());
4795    }
4796
4797    #[tokio::test]
4798    async fn delete_attachment_with_purge() {
4799        let server = wiremock::MockServer::start().await;
4800
4801        wiremock::Mock::given(wiremock::matchers::method("DELETE"))
4802            .and(wiremock::matchers::path("/wiki/api/v2/attachments/att-1"))
4803            .and(wiremock::matchers::query_param("purge", "true"))
4804            .respond_with(wiremock::ResponseTemplate::new(204))
4805            .expect(1)
4806            .mount(&server)
4807            .await;
4808
4809        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4810        let api = ConfluenceApi::new(client);
4811        assert!(api.delete_attachment("att-1", true).await.is_ok());
4812    }
4813
4814    #[tokio::test]
4815    async fn delete_attachment_not_found() {
4816        let server = wiremock::MockServer::start().await;
4817
4818        wiremock::Mock::given(wiremock::matchers::method("DELETE"))
4819            .and(wiremock::matchers::path("/wiki/api/v2/attachments/missing"))
4820            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
4821            .expect(1)
4822            .mount(&server)
4823            .await;
4824
4825        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4826        let api = ConfluenceApi::new(client);
4827        let err = api.delete_attachment("missing", false).await.unwrap_err();
4828        assert!(err.to_string().contains("404"));
4829    }
4830
4831    // ── resolve_attachment_download_url ────────────────────────────────
4832
4833    #[test]
4834    fn resolve_download_url_root_relative_gets_wiki_prefix() {
4835        assert_eq!(
4836            resolve_attachment_download_url(
4837                "https://org.atlassian.net",
4838                "/download/attachments/123/foo.png?version=1"
4839            ),
4840            "https://org.atlassian.net/wiki/download/attachments/123/foo.png?version=1"
4841        );
4842    }
4843
4844    #[test]
4845    fn resolve_download_url_already_has_wiki_prefix() {
4846        assert_eq!(
4847            resolve_attachment_download_url(
4848                "https://org.atlassian.net/",
4849                "/wiki/download/attachments/123/foo.png"
4850            ),
4851            "https://org.atlassian.net/wiki/download/attachments/123/foo.png"
4852        );
4853    }
4854
4855    #[test]
4856    fn resolve_download_url_absolute_passes_through() {
4857        let abs = "https://api.media.atlassian.com/file/abc/binary?token=xyz";
4858        assert_eq!(
4859            resolve_attachment_download_url("https://org.atlassian.net", abs),
4860            abs
4861        );
4862    }
4863
4864    #[test]
4865    fn resolve_download_url_bare_relative_gets_wiki_prefix() {
4866        assert_eq!(
4867            resolve_attachment_download_url("https://org.atlassian.net", "download/x"),
4868            "https://org.atlassian.net/wiki/download/x"
4869        );
4870    }
4871
4872    // ── get_attachment / download_attachment ───────────────────────────
4873
4874    #[tokio::test]
4875    async fn get_attachment_maps_v2_fields() {
4876        let server = wiremock::MockServer::start().await;
4877
4878        wiremock::Mock::given(wiremock::matchers::method("GET"))
4879            .and(wiremock::matchers::path("/wiki/api/v2/attachments/att-1"))
4880            .respond_with(
4881                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
4882                    "id": "att-1",
4883                    "title": "diagram.png",
4884                    "mediaType": "image/png",
4885                    "fileSize": 2048,
4886                    "downloadLink": "/download/attachments/12345/diagram.png?version=2",
4887                    "version": {"number": 2},
4888                    "pageId": "12345",
4889                    "fileId": "file-1"
4890                })),
4891            )
4892            .expect(1)
4893            .mount(&server)
4894            .await;
4895
4896        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4897        let api = ConfluenceApi::new(client);
4898        let attachment = api.get_attachment("att-1").await.unwrap();
4899
4900        assert_eq!(attachment.id, "att-1");
4901        assert_eq!(attachment.title, "diagram.png");
4902        assert_eq!(attachment.media_type.as_deref(), Some("image/png"));
4903        assert_eq!(attachment.file_size, Some(2048));
4904        assert_eq!(
4905            attachment.download_url.as_deref(),
4906            Some("/download/attachments/12345/diagram.png?version=2")
4907        );
4908        assert_eq!(attachment.version, Some(2));
4909        assert_eq!(attachment.page_id.as_deref(), Some("12345"));
4910        assert_eq!(attachment.file_id.as_deref(), Some("file-1"));
4911    }
4912
4913    #[tokio::test]
4914    async fn get_attachment_not_found() {
4915        let server = wiremock::MockServer::start().await;
4916
4917        wiremock::Mock::given(wiremock::matchers::method("GET"))
4918            .and(wiremock::matchers::path("/wiki/api/v2/attachments/missing"))
4919            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
4920            .expect(1)
4921            .mount(&server)
4922            .await;
4923
4924        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4925        let api = ConfluenceApi::new(client);
4926        let err = api.get_attachment("missing").await.unwrap_err();
4927        assert!(err.to_string().contains("404"));
4928    }
4929
4930    #[tokio::test]
4931    async fn download_attachment_fetches_metadata_then_bytes() {
4932        let server = wiremock::MockServer::start().await;
4933
4934        wiremock::Mock::given(wiremock::matchers::method("GET"))
4935            .and(wiremock::matchers::path("/wiki/api/v2/attachments/att-1"))
4936            .respond_with(
4937                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
4938                    "id": "att-1",
4939                    "title": "notes.txt",
4940                    "mediaType": "text/plain",
4941                    "fileSize": 5,
4942                    "downloadLink": "/download/attachments/12345/notes.txt"
4943                })),
4944            )
4945            .expect(1)
4946            .mount(&server)
4947            .await;
4948
4949        wiremock::Mock::given(wiremock::matchers::method("GET"))
4950            .and(wiremock::matchers::path(
4951                "/wiki/download/attachments/12345/notes.txt",
4952            ))
4953            .respond_with(wiremock::ResponseTemplate::new(200).set_body_bytes(b"hello".to_vec()))
4954            .expect(1)
4955            .mount(&server)
4956            .await;
4957
4958        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4959        let api = ConfluenceApi::new(client);
4960        let (attachment, bytes) = api.download_attachment("att-1").await.unwrap();
4961
4962        assert_eq!(attachment.title, "notes.txt");
4963        assert_eq!(bytes, b"hello");
4964    }
4965
4966    #[tokio::test]
4967    async fn download_attachment_bytes_missing_url_errors() {
4968        let server = wiremock::MockServer::start().await;
4969        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4970        let api = ConfluenceApi::new(client);
4971        let attachment = ConfluenceAttachment {
4972            id: "att-1".to_string(),
4973            title: "x.txt".to_string(),
4974            media_type: None,
4975            file_size: None,
4976            download_url: None,
4977            version: None,
4978            page_id: None,
4979            file_id: None,
4980        };
4981        let err = api
4982            .download_attachment_bytes(&attachment)
4983            .await
4984            .unwrap_err();
4985        assert!(err.to_string().contains("no download URL"));
4986    }
4987
4988    #[test]
4989    fn confluence_attachment_serialize_skips_none_fields() {
4990        let attachment = ConfluenceAttachment {
4991            id: "att-1".to_string(),
4992            title: "x.txt".to_string(),
4993            media_type: None,
4994            file_size: None,
4995            download_url: None,
4996            version: None,
4997            page_id: None,
4998            file_id: None,
4999        };
5000        let json = serde_json::to_value(&attachment).unwrap();
5001        assert_eq!(json["id"], "att-1");
5002        assert_eq!(json["title"], "x.txt");
5003        // Optional fields should be entirely absent (skip_serializing_if).
5004        assert!(json.get("media_type").is_none());
5005        assert!(json.get("file_size").is_none());
5006        assert!(json.get("download_url").is_none());
5007        assert!(json.get("version").is_none());
5008        assert!(json.get("page_id").is_none());
5009        assert!(json.get("file_id").is_none());
5010    }
5011
5012    #[test]
5013    fn confluence_attachment_serialize_includes_some_fields() {
5014        let attachment = ConfluenceAttachment {
5015            id: "att-1".to_string(),
5016            title: "x.txt".to_string(),
5017            media_type: Some("text/plain".to_string()),
5018            file_size: Some(42),
5019            download_url: Some("/dl".to_string()),
5020            version: Some(3),
5021            page_id: Some("12345".to_string()),
5022            file_id: Some("f-1".to_string()),
5023        };
5024        let json = serde_json::to_value(&attachment).unwrap();
5025        assert_eq!(json["media_type"], "text/plain");
5026        assert_eq!(json["file_size"], 42);
5027        assert_eq!(json["version"], 3);
5028    }
5029
5030    #[test]
5031    fn confluence_attachment_page_serialize_skips_none_cursor() {
5032        let page = ConfluenceAttachmentPage {
5033            results: vec![],
5034            next_cursor: None,
5035        };
5036        let json = serde_json::to_value(&page).unwrap();
5037        assert!(json.get("next_cursor").is_none());
5038    }
5039
5040    // ── get_page_at_version ───────────────────────────────────────
5041
5042    async fn mount_page_version(
5043        server: &wiremock::MockServer,
5044        page_id: &str,
5045        version: u32,
5046        adf_value: &str,
5047    ) {
5048        wiremock::Mock::given(wiremock::matchers::method("GET"))
5049            .and(wiremock::matchers::path(format!(
5050                "/wiki/api/v2/pages/{page_id}"
5051            )))
5052            .and(wiremock::matchers::query_param(
5053                "version",
5054                version.to_string(),
5055            ))
5056            .respond_with(
5057                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
5058                    "id": page_id,
5059                    "title": format!("Page {page_id} v{version}"),
5060                    "status": "current",
5061                    "spaceId": "98",
5062                    "version": {"number": version},
5063                    "body": {
5064                        "atlas_doc_format": {"value": adf_value}
5065                    }
5066                })),
5067            )
5068            .mount(server)
5069            .await;
5070
5071        wiremock::Mock::given(wiremock::matchers::method("GET"))
5072            .and(wiremock::matchers::path("/wiki/api/v2/spaces/98"))
5073            .respond_with(
5074                wiremock::ResponseTemplate::new(200)
5075                    .set_body_json(serde_json::json!({"key": "ENG"})),
5076            )
5077            .mount(server)
5078            .await;
5079    }
5080
5081    #[tokio::test]
5082    async fn get_page_at_version_success() {
5083        use crate::atlassian::api::ContentMetadata;
5084
5085        let server = wiremock::MockServer::start().await;
5086        mount_page_version(
5087            &server,
5088            "12",
5089            3,
5090            r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"v3"}]}]}"#,
5091        )
5092        .await;
5093
5094        let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
5095        let api = ConfluenceApi::new(client);
5096        let item = api.get_page_at_version("12", 3).await.unwrap();
5097        assert_eq!(item.id, "12");
5098        assert_eq!(item.title, "Page 12 v3");
5099        assert!(item.body_adf.is_some());
5100        match item.metadata {
5101            ContentMetadata::Confluence {
5102                space_key, version, ..
5103            } => {
5104                assert_eq!(space_key, "ENG");
5105                assert_eq!(version, Some(3));
5106            }
5107            ContentMetadata::Jira { .. } => panic!("expected Confluence metadata"),
5108        }
5109    }
5110
5111    #[tokio::test]
5112    async fn get_page_at_version_404() {
5113        let server = wiremock::MockServer::start().await;
5114        wiremock::Mock::given(wiremock::matchers::method("GET"))
5115            .and(wiremock::matchers::path("/wiki/api/v2/pages/12"))
5116            .and(wiremock::matchers::query_param("version", "99"))
5117            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
5118            .mount(&server)
5119            .await;
5120
5121        let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
5122        let api = ConfluenceApi::new(client);
5123        let err = api.get_page_at_version("12", 99).await.unwrap_err();
5124        assert!(err.to_string().contains("404"));
5125    }
5126
5127    #[tokio::test]
5128    async fn get_page_at_version_no_body() {
5129        let server = wiremock::MockServer::start().await;
5130        wiremock::Mock::given(wiremock::matchers::method("GET"))
5131            .and(wiremock::matchers::path("/wiki/api/v2/pages/12"))
5132            .and(wiremock::matchers::query_param("version", "1"))
5133            .respond_with(
5134                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
5135                    "id": "12",
5136                    "title": "Empty",
5137                    "status": "current",
5138                    "spaceId": "1",
5139                    "version": {"number": 1}
5140                })),
5141            )
5142            .mount(&server)
5143            .await;
5144        wiremock::Mock::given(wiremock::matchers::method("GET"))
5145            .and(wiremock::matchers::path("/wiki/api/v2/spaces/1"))
5146            .respond_with(
5147                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({"key": "S"})),
5148            )
5149            .mount(&server)
5150            .await;
5151
5152        let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
5153        let api = ConfluenceApi::new(client);
5154        let item = api.get_page_at_version("12", 1).await.unwrap();
5155        assert!(item.body_adf.is_none());
5156    }
5157
5158    // ── resolve_version ───────────────────────────────────────────
5159
5160    fn version_at(number: u32, created: &str) -> PageVersion {
5161        PageVersion {
5162            number,
5163            created_at: created.to_string(),
5164            author_id: String::new(),
5165            message: String::new(),
5166            minor_edit: false,
5167        }
5168    }
5169
5170    fn fixture_versions() -> Vec<PageVersion> {
5171        // Newest-first.
5172        vec![
5173            version_at(5, "2026-05-09T10:00:00Z"),
5174            version_at(4, "2026-05-08T10:00:00Z"),
5175            version_at(3, "2026-05-07T10:00:00Z"),
5176            version_at(2, "2026-05-06T10:00:00Z"),
5177            version_at(1, "2026-05-05T10:00:00Z"),
5178        ]
5179    }
5180
5181    #[test]
5182    fn resolve_version_latest() {
5183        let v = fixture_versions();
5184        assert_eq!(resolve_version("latest", &v, 5).unwrap(), 5);
5185        assert_eq!(resolve_version("LATEST", &v, 5).unwrap(), 5);
5186    }
5187
5188    #[test]
5189    fn resolve_version_previous_relative_to_anchor() {
5190        let v = fixture_versions();
5191        // `previous` is relative to `relative_to`, not to head.
5192        assert_eq!(resolve_version("previous", &v, 5).unwrap(), 4);
5193        assert_eq!(resolve_version("previous", &v, 3).unwrap(), 2);
5194    }
5195
5196    #[test]
5197    fn resolve_version_previous_at_first_version_errors() {
5198        let v = fixture_versions();
5199        let err = resolve_version("previous", &v, 1).unwrap_err();
5200        assert!(err.to_string().contains("out of range"));
5201    }
5202
5203    #[test]
5204    fn resolve_version_v_minus_offset() {
5205        let v = fixture_versions();
5206        assert_eq!(resolve_version("v-2", &v, 5).unwrap(), 3);
5207        assert_eq!(resolve_version("V-1", &v, 5).unwrap(), 4);
5208    }
5209
5210    #[test]
5211    fn resolve_version_v_minus_zero_rejected() {
5212        let v = fixture_versions();
5213        let err = resolve_version("v-0", &v, 5).unwrap_err();
5214        assert!(err.to_string().contains("> 0"));
5215    }
5216
5217    #[test]
5218    fn resolve_version_v_minus_too_deep() {
5219        let v = fixture_versions();
5220        let err = resolve_version("v-10", &v, 5).unwrap_err();
5221        assert!(err.to_string().contains("out of range"));
5222    }
5223
5224    #[test]
5225    fn resolve_version_numeric_in_range() {
5226        let v = fixture_versions();
5227        assert_eq!(resolve_version("3", &v, 5).unwrap(), 3);
5228    }
5229
5230    #[test]
5231    fn resolve_version_numeric_not_present() {
5232        let v = fixture_versions();
5233        let err = resolve_version("99", &v, 5).unwrap_err();
5234        assert!(err.to_string().contains("not found"));
5235    }
5236
5237    #[test]
5238    fn resolve_version_iso_picks_latest_at_or_before() {
5239        let v = fixture_versions();
5240        // 2026-05-08T11:00:00Z is after v4 (10:00) but before v5 (next day),
5241        // so the latest version at-or-before is v4.
5242        assert_eq!(resolve_version("2026-05-08T11:00:00Z", &v, 5).unwrap(), 4);
5243        assert_eq!(resolve_version("2026-05-09T10:00:00Z", &v, 5).unwrap(), 5);
5244        // Date-only: Confluence returns full timestamps; lexicographic
5245        // compare against `2026-05-07` matches versions with empty
5246        // created_at NOT, but matches v with `2026-05-07T...` only when
5247        // the timestamp is `<= "2026-05-07"`. Use a clearly past value.
5248        assert_eq!(resolve_version("2026-05-06", &v, 5).unwrap(), 1);
5249    }
5250
5251    #[test]
5252    fn resolve_version_iso_no_match_errors() {
5253        let v = fixture_versions();
5254        let err = resolve_version("2020-01-01", &v, 5).unwrap_err();
5255        assert!(err.to_string().contains("at or before"));
5256    }
5257
5258    #[test]
5259    fn resolve_version_empty_versions_errors() {
5260        let err = resolve_version("latest", &[], 0).unwrap_err();
5261        assert!(err.to_string().contains("no versions"));
5262    }
5263
5264    #[test]
5265    fn resolve_version_empty_string_rejected() {
5266        let v = fixture_versions();
5267        let err = resolve_version("   ", &v, 5).unwrap_err();
5268        assert!(err.to_string().contains("empty"));
5269    }
5270
5271    #[test]
5272    fn resolve_version_unparseable() {
5273        let v = fixture_versions();
5274        let err = resolve_version("garbage", &v, 5).unwrap_err();
5275        assert!(err.to_string().contains("Could not parse"));
5276    }
5277
5278    #[test]
5279    fn resolve_version_v_minus_with_non_numeric_suffix_rejected() {
5280        // Exercises the `with_context` error path when v-N's suffix fails
5281        // to parse as a u32.
5282        let v = fixture_versions();
5283        let err = resolve_version("v-abc", &v, 5).unwrap_err();
5284        assert!(
5285            err.to_string().contains("Invalid relative version offset"),
5286            "got: {err}"
5287        );
5288    }
5289
5290    #[test]
5291    fn resolve_version_v_minus_resolves_to_missing_version_errors() {
5292        // Anchor (4) > offset (2), so the offset doesn't go negative — but
5293        // version 2 isn't in the truncated history. Exercises the
5294        // "Version N not found" path inside `offset_from`.
5295        let versions = vec![
5296            version_at(5, "2026-05-09T10:00:00Z"),
5297            version_at(4, "2026-05-08T10:00:00Z"),
5298            // v3 and v2 are absent (e.g., the cap dropped them).
5299        ];
5300        let err = resolve_version("v-2", &versions, 4).unwrap_err();
5301        assert!(err.to_string().contains("not found"), "got: {err}");
5302    }
5303
5304    #[tokio::test]
5305    async fn get_page_at_version_with_body_but_no_atlas_doc_format() {
5306        // Exercises the `else { None }` arm where body is present but
5307        // `atlas_doc_format` is missing.
5308        let server = wiremock::MockServer::start().await;
5309        wiremock::Mock::given(wiremock::matchers::method("GET"))
5310            .and(wiremock::matchers::path("/wiki/api/v2/pages/12"))
5311            .and(wiremock::matchers::query_param("version", "1"))
5312            .respond_with(
5313                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
5314                    "id": "12",
5315                    "title": "T",
5316                    "status": "current",
5317                    "spaceId": "1",
5318                    "version": {"number": 1},
5319                    "body": { /* atlas_doc_format absent */ }
5320                })),
5321            )
5322            .mount(&server)
5323            .await;
5324        wiremock::Mock::given(wiremock::matchers::method("GET"))
5325            .and(wiremock::matchers::path("/wiki/api/v2/spaces/1"))
5326            .respond_with(
5327                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({"key": "S"})),
5328            )
5329            .mount(&server)
5330            .await;
5331
5332        let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
5333        let api = ConfluenceApi::new(client);
5334        let item = api.get_page_at_version("12", 1).await.unwrap();
5335        assert!(item.body_adf.is_none());
5336    }
5337}