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