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