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