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