use std::future::Future;
use std::path::Path;
use std::pin::Pin;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use tokio_util::io::ReaderStream;
use tracing::debug;
use crate::atlassian::adf::AdfDocument;
use crate::atlassian::adf_hints;
use crate::atlassian::adf_schema;
use crate::atlassian::adf_validated::ValidatedAdfDocument;
use crate::atlassian::api::{AtlassianApi, ContentItem, ContentMetadata};
use crate::atlassian::client::AtlassianClient;
use crate::atlassian::error::AtlassianError;
fn confluence_write_error(status: u16, body: String, body_adf: &AdfDocument) -> anyhow::Error {
if status == 500 {
if let Some(violation) = adf_schema::validate_document(body_adf).into_iter().next() {
let hint = adf_hints::hint_for(&violation).map(str::to_string);
return AtlassianError::ApiRequestFailedWithDiagnosis {
body,
diagnosis: violation,
hint,
}
.into();
}
}
AtlassianError::ApiRequestFailed { status, body }.into()
}
pub struct ConfluenceApi {
client: AtlassianClient,
}
impl ConfluenceApi {
pub fn new(client: AtlassianClient) -> Self {
Self { client }
}
}
#[derive(Deserialize)]
struct ConfluencePageResponse {
id: String,
title: String,
status: String,
#[serde(rename = "spaceId")]
space_id: String,
version: Option<ConfluenceVersion>,
body: Option<ConfluenceBody>,
#[serde(rename = "parentId")]
parent_id: Option<String>,
#[serde(default)]
ancestors: Vec<ConfluenceAncestorEntry>,
}
#[derive(Deserialize)]
struct ConfluenceAncestorEntry {
id: String,
}
#[derive(Deserialize)]
struct ConfluenceVersion {
number: u32,
}
#[derive(Deserialize)]
struct ConfluenceBody {
atlas_doc_format: Option<ConfluenceAtlasDoc>,
}
#[derive(Deserialize)]
struct ConfluenceAtlasDoc {
value: String,
}
#[derive(Deserialize)]
struct ConfluenceSpaceResponse {
key: String,
}
#[derive(Deserialize)]
struct ConfluenceSpacesSearchResponse {
results: Vec<ConfluenceSpaceSearchEntry>,
}
#[derive(Deserialize)]
struct ConfluenceSpaceSearchEntry {
id: String,
}
#[derive(Deserialize)]
struct ConfluenceChildrenResponse {
results: Vec<ConfluenceChildEntry>,
#[serde(rename = "_links", default)]
links: Option<ConfluenceChildrenLinks>,
}
#[derive(Deserialize)]
struct ConfluenceChildEntry {
id: String,
title: String,
#[serde(default)]
status: Option<String>,
}
#[derive(Deserialize)]
struct ConfluenceChildrenLinks {
next: Option<String>,
}
#[derive(Deserialize)]
struct ConfluenceSpacePagesResponse {
results: Vec<ConfluenceSpacePageEntry>,
#[serde(rename = "_links", default)]
links: Option<ConfluenceChildrenLinks>,
}
#[derive(Deserialize)]
struct ConfluenceSpacePageEntry {
id: String,
title: String,
#[serde(default)]
status: Option<String>,
#[serde(rename = "parentId", default)]
parent_id: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ChildPage {
pub id: String,
pub title: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub status: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub space_key: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ConfluenceComment {
pub id: String,
pub author: String,
pub body_adf: Option<serde_json::Value>,
pub created: String,
}
#[derive(Deserialize)]
struct ConfluenceCommentsResponse {
results: Vec<ConfluenceCommentEntry>,
#[serde(rename = "_links", default)]
links: Option<ConfluenceCommentsLinks>,
}
#[derive(Deserialize)]
struct ConfluenceCommentsLinks {
next: Option<String>,
}
#[derive(Deserialize)]
struct ConfluenceCommentEntry {
id: String,
#[serde(default)]
version: Option<ConfluenceCommentVersion>,
#[serde(default)]
body: Option<ConfluenceCommentBody>,
}
#[derive(Deserialize)]
struct ConfluenceCommentVersion {
#[serde(rename = "authorId", default)]
author_id: Option<String>,
#[serde(rename = "createdAt", default)]
created_at: Option<String>,
}
#[derive(Deserialize)]
struct ConfluenceCommentBody {
atlas_doc_format: Option<ConfluenceAtlasDoc>,
}
#[derive(Serialize)]
struct ConfluenceAddCommentRequest {
#[serde(rename = "pageId")]
page_id: String,
body: ConfluenceUpdateBody,
}
#[derive(Deserialize)]
struct ConfluenceLabelsResponse {
results: Vec<ConfluenceLabelEntry>,
#[serde(rename = "_links", default)]
links: Option<ConfluenceLabelsLinks>,
}
#[derive(Deserialize)]
struct ConfluenceLabelEntry {
id: String,
name: String,
prefix: String,
}
#[derive(Deserialize)]
struct ConfluenceLabelsLinks {
next: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ConfluenceLabel {
pub id: String,
pub name: String,
pub prefix: String,
}
#[derive(Serialize)]
struct ConfluenceAddLabelEntry {
prefix: String,
name: String,
}
#[derive(Deserialize)]
struct ConfluenceVersionsResponse {
results: Vec<ConfluenceVersionEntry>,
#[serde(rename = "_links", default)]
links: Option<ConfluenceVersionsLinks>,
}
#[derive(Deserialize)]
struct ConfluenceVersionEntry {
number: u32,
#[serde(rename = "createdAt", default)]
created_at: Option<String>,
#[serde(default)]
message: Option<String>,
#[serde(rename = "minorEdit", default)]
minor_edit: Option<bool>,
#[serde(rename = "authorId", default)]
author_id: Option<String>,
}
#[derive(Deserialize)]
struct ConfluenceVersionsLinks {
next: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PageVersion {
pub number: u32,
#[serde(default)]
pub created_at: String,
#[serde(default)]
pub author_id: String,
#[serde(default)]
pub message: String,
#[serde(default)]
pub minor_edit: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SinceFilter {
Version(u32),
CreatedAt(String),
}
impl SinceFilter {
pub fn parse(raw: &str) -> Result<Self> {
let trimmed = raw.trim();
if trimmed.is_empty() {
anyhow::bail!("`since` must be a version number or ISO 8601 date");
}
if trimmed.chars().all(|c| c.is_ascii_digit()) {
let n: u32 = trimmed
.parse()
.with_context(|| format!("Invalid version number \"{trimmed}\""))?;
return Ok(Self::Version(n));
}
if trimmed.contains('-') || trimmed.contains('T') {
return Ok(Self::CreatedAt(trimmed.to_string()));
}
anyhow::bail!(
"`since` must be a numeric version (e.g. \"5\") or ISO 8601 date \
(e.g. \"2026-01-01T00:00:00Z\"); got \"{trimmed}\""
);
}
fn matches(&self, version: &PageVersion) -> bool {
match self {
Self::Version(min) => version.number >= *min,
Self::CreatedAt(min) => {
if version.created_at.is_empty() {
false
} else {
version.created_at.as_str() >= min.as_str()
}
}
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct PageMetadata {
pub id: String,
pub title: String,
pub current_version: Option<u32>,
}
#[derive(Deserialize)]
struct ConfluenceAttachmentsResponse {
results: Vec<ConfluenceAttachmentEntry>,
#[serde(rename = "_links", default)]
links: Option<ConfluenceAttachmentLinks>,
}
#[derive(Deserialize)]
struct ConfluenceAttachmentLinks {
next: Option<String>,
}
#[derive(Deserialize)]
struct ConfluenceAttachmentEntry {
id: String,
title: String,
#[serde(rename = "mediaType", default)]
media_type: Option<String>,
#[serde(rename = "fileSize", default)]
file_size: Option<u64>,
#[serde(rename = "downloadLink", default)]
download_link: Option<String>,
#[serde(default)]
version: Option<ConfluenceAttachmentVersion>,
#[serde(rename = "pageId", default)]
page_id: Option<String>,
#[serde(rename = "fileId", default)]
file_id: Option<String>,
}
#[derive(Deserialize)]
struct ConfluenceAttachmentVersion {
number: u32,
}
#[derive(Debug, Clone, Serialize)]
pub struct ConfluenceAttachment {
pub id: String,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub media_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub file_size: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub download_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub page_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub file_id: Option<String>,
}
impl From<ConfluenceAttachmentEntry> for ConfluenceAttachment {
fn from(e: ConfluenceAttachmentEntry) -> Self {
Self {
id: e.id,
title: e.title,
media_type: e.media_type,
file_size: e.file_size,
download_url: e.download_link,
version: e.version.map(|v| v.number),
page_id: e.page_id,
file_id: e.file_id,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ConfluenceAttachmentPage {
pub results: Vec<ConfluenceAttachment>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
}
#[derive(Serialize)]
struct ConfluenceCreateRequest {
#[serde(rename = "spaceId")]
space_id: String,
title: String,
body: ConfluenceUpdateBody,
#[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
parent_id: Option<String>,
status: String,
}
#[derive(Deserialize)]
struct ConfluenceCreateResponse {
id: String,
}
#[derive(Serialize)]
struct ConfluenceUpdateRequest {
id: String,
status: String,
title: String,
body: ConfluenceUpdateBody,
version: ConfluenceUpdateVersion,
}
#[derive(Serialize)]
struct ConfluenceUpdateBody {
representation: String,
value: String,
}
#[derive(Serialize)]
struct ConfluenceUpdateVersion {
number: u32,
message: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MovePosition {
Append,
Before,
After,
}
impl MovePosition {
pub fn as_str(self) -> &'static str {
match self {
Self::Append => "append",
Self::Before => "before",
Self::After => "after",
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct MovedPage {
pub id: String,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_id: Option<String>,
pub ancestors: Vec<String>,
}
impl AtlassianApi for ConfluenceApi {
fn get_content<'a>(
&'a self,
id: &'a str,
) -> Pin<Box<dyn Future<Output = Result<ContentItem>> + Send + 'a>> {
Box::pin(async move {
let url = format!(
"{}/wiki/api/v2/pages/{}?body-format=atlas_doc_format",
self.client.instance_url(),
id
);
let response = self
.client
.get_json(&url)
.await
.context("Failed to fetch Confluence page")?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
let page: ConfluencePageResponse = response
.json()
.await
.context("Failed to parse Confluence page response")?;
debug!(
page_id = page.id,
title = page.title,
"Fetched Confluence page"
);
let body_adf = if let Some(body) = &page.body {
if let Some(atlas_doc) = &body.atlas_doc_format {
if tracing::enabled!(tracing::Level::TRACE) {
if let Ok(pretty) =
serde_json::from_str::<serde_json::Value>(&atlas_doc.value)
.and_then(|v| serde_json::to_string_pretty(&v))
{
tracing::trace!("Original ADF from Confluence:\n{pretty}");
}
}
Some(
serde_json::from_str(&atlas_doc.value)
.context("Failed to parse ADF from Confluence body")?,
)
} else {
None
}
} else {
None
};
let space_key = self.resolve_space_key(&page.space_id).await?;
Ok(ContentItem {
id: page.id,
title: page.title,
body_adf,
metadata: ContentMetadata::Confluence {
space_key,
status: Some(page.status),
version: page.version.map(|v| v.number),
parent_id: page.parent_id,
},
})
})
}
fn update_content<'a>(
&'a self,
id: &'a str,
body_adf: &'a ValidatedAdfDocument,
title: Option<&'a str>,
) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>> {
Box::pin(async move {
let current = self.get_content(id).await?;
let current_version = match ¤t.metadata {
ContentMetadata::Confluence { version, .. } => version.unwrap_or(1),
ContentMetadata::Jira { .. } => 1,
};
let current_title = current.title;
let adf_json =
serde_json::to_string(body_adf).context("Failed to serialize ADF document")?;
debug!(
page_id = id,
version = current_version + 1,
adf_bytes = adf_json.len(),
"Updating Confluence page"
);
if tracing::enabled!(tracing::Level::TRACE) {
let pretty = serde_json::to_string_pretty(body_adf)
.unwrap_or_else(|e| format!("<serialization error: {e}>"));
tracing::trace!("ADF body for update:\n{pretty}");
}
let update = ConfluenceUpdateRequest {
id: id.to_string(),
status: "current".to_string(),
title: title.unwrap_or(¤t_title).to_string(),
body: ConfluenceUpdateBody {
representation: "atlas_doc_format".to_string(),
value: adf_json,
},
version: ConfluenceUpdateVersion {
number: current_version + 1,
message: None,
},
};
let url = format!("{}/wiki/api/v2/pages/{}", self.client.instance_url(), id);
let response = self
.client
.put_json(&url, &update)
.await
.context("Failed to update Confluence page")?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
debug!(status, body = %body, "Confluence update_content non-success");
return Err(confluence_write_error(status, body, body_adf));
}
Ok(())
})
}
fn verify_auth<'a>(&'a self) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>> {
Box::pin(async move {
let user = self.client.get_myself().await?;
Ok(user.display_name)
})
}
fn backend_name(&self) -> &'static str {
"confluence"
}
}
impl ConfluenceApi {
pub async fn resolve_space_id(&self, space_key: &str) -> Result<String> {
let url = format!(
"{}/wiki/api/v2/spaces?keys={}",
self.client.instance_url(),
space_key
);
let response = self
.client
.get_json(&url)
.await
.context("Failed to search Confluence spaces")?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
let resp: ConfluenceSpacesSearchResponse = response
.json()
.await
.context("Failed to parse Confluence spaces response")?;
resp.results
.first()
.map(|s| s.id.clone())
.ok_or_else(|| anyhow::anyhow!("Space with key \"{space_key}\" not found"))
}
pub async fn create_page(
&self,
space_key: &str,
title: &str,
body_adf: &ValidatedAdfDocument,
parent_id: Option<&str>,
) -> Result<String> {
let space_id = self.resolve_space_id(space_key).await?;
let adf_json =
serde_json::to_string(body_adf).context("Failed to serialize ADF document")?;
let request = ConfluenceCreateRequest {
space_id,
title: title.to_string(),
body: ConfluenceUpdateBody {
representation: "atlas_doc_format".to_string(),
value: adf_json,
},
parent_id: parent_id.map(String::from),
status: "current".to_string(),
};
let url = format!("{}/wiki/api/v2/pages", self.client.instance_url());
let response = self
.client
.post_json(&url, &request)
.await
.context("Failed to create Confluence page")?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
debug!(status, body = %body, "Confluence create_page non-success");
return Err(confluence_write_error(status, body, body_adf));
}
let resp: ConfluenceCreateResponse = response
.json()
.await
.context("Failed to parse Confluence create response")?;
Ok(resp.id)
}
pub async fn move_page(
&self,
page_id: &str,
target_id: &str,
position: MovePosition,
) -> Result<MovedPage> {
let url = format!(
"{}/wiki/rest/api/content/{}/move/{}/{}",
self.client.instance_url(),
page_id,
position.as_str(),
target_id
);
let response = self
.client
.put_json(&url, &serde_json::json!({}))
.await
.context("Failed to send Confluence move request")?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
if status == 403 {
anyhow::bail!(
"Move failed: insufficient permissions to move page {page_id} \
relative to target {target_id}. Confluence response: {body}"
);
}
if status == 404 {
anyhow::bail!(
"Move failed: page {page_id} or target {target_id} not found, \
or insufficient permissions. Confluence response: {body}"
);
}
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
let page = self.fetch_page_with_ancestors(page_id).await?;
Ok(MovedPage {
id: page.id,
title: page.title,
parent_id: page.parent_id,
ancestors: page.ancestors.into_iter().map(|a| a.id).collect(),
})
}
async fn fetch_page_with_ancestors(&self, id: &str) -> Result<ConfluencePageResponse> {
let url = format!(
"{}/wiki/api/v2/pages/{}?include-ancestors=true",
self.client.instance_url(),
id
);
let response = self
.client
.get_json(&url)
.await
.context("Failed to fetch Confluence page with ancestors")?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
response
.json()
.await
.context("Failed to parse Confluence page response")
}
pub async fn delete_page(&self, id: &str, purge: bool) -> Result<()> {
let mut url = format!("{}/wiki/api/v2/pages/{}", self.client.instance_url(), id);
if purge {
url.push_str("?purge=true");
}
let response = self.client.delete(&url).await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
if status == 404 {
anyhow::bail!(
"Page {id} not found or insufficient permissions. \
Confluence returns 404 when the API user lacks space-level delete permission. \
Check Space Settings > Permissions."
);
}
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
Ok(())
}
pub async fn get_children(&self, page_id: &str) -> Result<Vec<ChildPage>> {
let mut all_children = Vec::new();
let mut url = format!(
"{}/wiki/rest/api/content/{}/child/page?limit=50",
self.client.instance_url(),
page_id
);
loop {
let response = self
.client
.get_json(&url)
.await
.context("Failed to fetch child pages")?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
let resp: ConfluenceChildrenResponse = response
.json()
.await
.context("Failed to parse children response")?;
let page_count = resp.results.len();
for child in resp.results {
all_children.push(ChildPage {
id: child.id,
title: child.title,
status: child.status.unwrap_or_default(),
parent_id: Some(page_id.to_string()),
space_key: None,
});
}
match resp.links.and_then(|l| l.next) {
Some(next_path) if page_count > 0 => {
url = format!("{}{}", self.client.instance_url(), next_path);
}
_ => break,
}
}
Ok(all_children)
}
pub async fn get_space_root_pages(&self, space_id: &str) -> Result<Vec<ChildPage>> {
let mut all_pages = Vec::new();
let mut url = format!(
"{}/wiki/api/v2/spaces/{}/pages?depth=root&limit=50",
self.client.instance_url(),
space_id
);
loop {
let response = self
.client
.get_json(&url)
.await
.context("Failed to fetch space root pages")?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
let resp: ConfluenceSpacePagesResponse = response
.json()
.await
.context("Failed to parse space pages response")?;
let page_count = resp.results.len();
for entry in resp.results {
all_pages.push(ChildPage {
id: entry.id,
title: entry.title,
status: entry.status.unwrap_or_default(),
parent_id: entry.parent_id,
space_key: None,
});
}
match resp.links.and_then(|l| l.next) {
Some(next_path) if page_count > 0 => {
url = format!("{}{}", self.client.instance_url(), next_path);
}
_ => break,
}
}
Ok(all_pages)
}
pub async fn get_page_comments(&self, page_id: &str) -> Result<Vec<ConfluenceComment>> {
let mut all_comments = Vec::new();
let mut url = format!(
"{}/wiki/api/v2/pages/{}/footer-comments?body-format=atlas_doc_format",
self.client.instance_url(),
page_id
);
loop {
let response = self
.client
.get_json(&url)
.await
.context("Failed to fetch Confluence page comments")?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
let resp: ConfluenceCommentsResponse = response
.json()
.await
.context("Failed to parse Confluence comments response")?;
let page_count = resp.results.len();
for c in resp.results {
let body_adf = c.body.and_then(|b| {
b.atlas_doc_format
.and_then(|a| serde_json::from_str(&a.value).ok())
});
let author = c
.version
.as_ref()
.and_then(|v| v.author_id.clone())
.unwrap_or_default();
let created = c.version.and_then(|v| v.created_at).unwrap_or_default();
all_comments.push(ConfluenceComment {
id: c.id,
author,
body_adf,
created,
});
}
match resp.links.and_then(|l| l.next) {
Some(next_path) if page_count > 0 => {
url = format!("{}{}", self.client.instance_url(), next_path);
}
_ => break,
}
}
Ok(all_comments)
}
pub async fn add_page_comment(
&self,
page_id: &str,
body_adf: &ValidatedAdfDocument,
) -> Result<()> {
let adf_json =
serde_json::to_string(body_adf).context("Failed to serialize ADF document")?;
let request = ConfluenceAddCommentRequest {
page_id: page_id.to_string(),
body: ConfluenceUpdateBody {
representation: "atlas_doc_format".to_string(),
value: adf_json,
},
};
let url = format!("{}/wiki/api/v2/footer-comments", self.client.instance_url());
let response = self
.client
.post_json(&url, &request)
.await
.context("Failed to add Confluence page comment")?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
debug!(status, body = %body, "Confluence add_page_comment non-success");
return Err(confluence_write_error(status, body, body_adf));
}
Ok(())
}
async fn resolve_space_key(&self, space_id: &str) -> Result<String> {
let url = format!(
"{}/wiki/api/v2/spaces/{}",
self.client.instance_url(),
space_id
);
let response = self
.client
.get_json(&url)
.await
.context("Failed to fetch Confluence space")?;
if !response.status().is_success() {
return Ok(space_id.to_string());
}
let space: ConfluenceSpaceResponse = response
.json()
.await
.context("Failed to parse Confluence space response")?;
Ok(space.key)
}
pub async fn get_labels(&self, page_id: &str) -> Result<Vec<ConfluenceLabel>> {
let mut all_labels = Vec::new();
let mut url = format!(
"{}/wiki/api/v2/pages/{}/labels",
self.client.instance_url(),
page_id
);
loop {
let response = self
.client
.get_json(&url)
.await
.context("Failed to fetch page labels")?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
let resp: ConfluenceLabelsResponse = response
.json()
.await
.context("Failed to parse labels response")?;
let page_count = resp.results.len();
for entry in resp.results {
all_labels.push(ConfluenceLabel {
id: entry.id,
name: entry.name,
prefix: entry.prefix,
});
}
match resp.links.and_then(|l| l.next) {
Some(next_path) if page_count > 0 => {
url = format!("{}{}", self.client.instance_url(), next_path);
}
_ => break,
}
}
Ok(all_labels)
}
pub async fn add_labels(&self, page_id: &str, labels: &[String]) -> Result<()> {
let url = format!(
"{}/wiki/rest/api/content/{}/label",
self.client.instance_url(),
page_id
);
let body: Vec<ConfluenceAddLabelEntry> = labels
.iter()
.map(|name| ConfluenceAddLabelEntry {
prefix: "global".to_string(),
name: name.clone(),
})
.collect();
let response = self
.client
.post_json(&url, &body)
.await
.context("Failed to add labels")?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
Ok(())
}
pub async fn remove_label(&self, page_id: &str, label_name: &str) -> Result<()> {
let url = format!(
"{}/wiki/rest/api/content/{}/label/{}",
self.client.instance_url(),
page_id,
label_name
);
let response = self.client.delete(&url).await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
Ok(())
}
pub async fn get_page_metadata(&self, page_id: &str) -> Result<PageMetadata> {
let url = format!(
"{}/wiki/api/v2/pages/{}",
self.client.instance_url(),
page_id
);
let response = self
.client
.get_json(&url)
.await
.context("Failed to fetch Confluence page metadata")?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
let page: ConfluencePageResponse = response
.json()
.await
.context("Failed to parse Confluence page response")?;
Ok(PageMetadata {
id: page.id,
title: page.title,
current_version: page.version.map(|v| v.number),
})
}
pub async fn list_page_versions(
&self,
page_id: &str,
since: Option<&SinceFilter>,
limit: u32,
) -> Result<(Vec<PageVersion>, bool)> {
let page_size = if limit == 0 { 100 } else { limit.min(100) };
let mut url = format!(
"{}/wiki/api/v2/pages/{}/versions?limit={}",
self.client.instance_url(),
page_id,
page_size
);
let mut collected: Vec<PageVersion> = Vec::new();
loop {
let response = self
.client
.get_json(&url)
.await
.context("Failed to fetch Confluence page versions")?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
let resp: ConfluenceVersionsResponse = response
.json()
.await
.context("Failed to parse Confluence versions response")?;
let page_count = resp.results.len();
let next_link = resp.links.and_then(|l| l.next);
for (idx, entry) in resp.results.into_iter().enumerate() {
let version = PageVersion {
number: entry.number,
created_at: entry.created_at.unwrap_or_default(),
author_id: entry.author_id.unwrap_or_default(),
message: entry.message.unwrap_or_default(),
minor_edit: entry.minor_edit.unwrap_or(false),
};
if let Some(filter) = since {
if !filter.matches(&version) {
return Ok((collected, false));
}
}
collected.push(version);
if limit > 0 && collected.len() as u32 >= limit {
let more_on_page = idx + 1 < page_count;
let has_next = next_link.is_some();
return Ok((collected, more_on_page || has_next));
}
}
match next_link {
Some(next_path) if page_count > 0 => {
url = format!("{}{}", self.client.instance_url(), next_path);
}
_ => return Ok((collected, false)),
}
}
}
pub async fn upload_attachment(
&self,
page_id: &str,
file_path: &Path,
filename: Option<&str>,
comment: Option<&str>,
minor_edit: bool,
) -> Result<ConfluenceAttachment> {
let metadata = tokio::fs::metadata(file_path)
.await
.with_context(|| format!("Failed to read file metadata for {}", file_path.display()))?;
let size = metadata.len();
let file = tokio::fs::File::open(file_path)
.await
.with_context(|| format!("Failed to open {}", file_path.display()))?;
let resolved_name = filename
.map(str::to_string)
.or_else(|| {
file_path
.file_name()
.map(|s| s.to_string_lossy().into_owned())
})
.ok_or_else(|| anyhow::anyhow!("File path has no filename component"))?;
let mime = mime_guess::from_path(file_path).first_or_octet_stream();
let stream = ReaderStream::new(file);
let body = reqwest::Body::wrap_stream(stream);
let part = reqwest::multipart::Part::stream_with_length(body, size)
.file_name(resolved_name.clone())
.mime_str(mime.essence_str())
.with_context(|| format!("Invalid MIME type for {}", file_path.display()))?;
let mut form = reqwest::multipart::Form::new().part("file", part);
if let Some(c) = comment {
form = form.text("comment", c.to_string());
}
form = form.text("minorEdit", if minor_edit { "true" } else { "false" });
let url = format!(
"{}/wiki/api/v2/pages/{}/attachments",
self.client.instance_url(),
page_id
);
let response = self
.client
.post_multipart(&url, form, &[("X-Atlassian-Token", "no-check")])
.await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
let resp: ConfluenceAttachmentsResponse = response
.json()
.await
.context("Failed to parse upload attachment response")?;
let entry = resp
.results
.into_iter()
.next()
.ok_or_else(|| anyhow::anyhow!("Upload response contained no attachment"))?;
Ok(entry.into())
}
pub async fn list_attachments(
&self,
page_id: &str,
cursor: Option<&str>,
limit: u32,
) -> Result<ConfluenceAttachmentPage> {
let mut url = format!(
"{}/wiki/api/v2/pages/{}/attachments?limit={}",
self.client.instance_url(),
page_id,
limit,
);
if let Some(c) = cursor {
url.push_str("&cursor=");
url.push_str(&urlencoding(c));
}
let response = self
.client
.get_json(&url)
.await
.context("Failed to fetch page attachments")?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
let resp: ConfluenceAttachmentsResponse = response
.json()
.await
.context("Failed to parse attachments response")?;
let next_cursor = resp
.links
.and_then(|l| l.next)
.and_then(|next_path| extract_cursor_from_next(&next_path));
let results = resp.results.into_iter().map(Into::into).collect();
Ok(ConfluenceAttachmentPage {
results,
next_cursor,
})
}
pub async fn delete_attachment(&self, attachment_id: &str, purge: bool) -> Result<()> {
let mut url = format!(
"{}/wiki/api/v2/attachments/{}",
self.client.instance_url(),
attachment_id
);
if purge {
url.push_str("?purge=true");
}
let response = self.client.delete(&url).await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
Ok(())
}
pub async fn get_page_at_version(&self, id: &str, version: u32) -> Result<ContentItem> {
let url = format!(
"{}/wiki/api/v2/pages/{}?body-format=atlas_doc_format&version={}",
self.client.instance_url(),
id,
version
);
let response = self
.client
.get_json(&url)
.await
.context("Failed to fetch Confluence page version")?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(AtlassianError::ApiRequestFailed { status, body }.into());
}
let page: ConfluencePageResponse = response
.json()
.await
.context("Failed to parse Confluence page response")?;
debug!(
page_id = page.id,
version,
title = page.title,
"Fetched Confluence page at specific version"
);
let body_adf = if let Some(body) = &page.body {
if let Some(atlas_doc) = &body.atlas_doc_format {
Some(
serde_json::from_str(&atlas_doc.value)
.context("Failed to parse ADF from Confluence body")?,
)
} else {
None
}
} else {
None
};
let space_key = self.resolve_space_key(&page.space_id).await?;
Ok(ContentItem {
id: page.id,
title: page.title,
body_adf,
metadata: ContentMetadata::Confluence {
space_key,
status: Some(page.status),
version: page.version.map(|v| v.number),
parent_id: page.parent_id,
},
})
}
}
fn urlencoding(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("%26"),
'=' => out.push_str("%3D"),
'+' => out.push_str("%2B"),
'%' => out.push_str("%25"),
'#' => out.push_str("%23"),
' ' => out.push_str("%20"),
_ => out.push(c),
}
}
out
}
fn extract_cursor_from_next(next: &str) -> Option<String> {
let query_start = next.find('?')?;
let query = &next[query_start + 1..];
for pair in query.split('&') {
let mut it = pair.splitn(2, '=');
let key = it.next()?;
let value = it.next().unwrap_or("");
if key == "cursor" {
return Some(percent_decode(value));
}
}
None
}
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hi = (bytes[i + 1] as char).to_digit(16);
let lo = (bytes[i + 2] as char).to_digit(16);
if let (Some(hi), Some(lo)) = (hi, lo) {
out.push(((hi << 4) | lo) as u8);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
pub fn resolve_version(raw: &str, versions: &[PageVersion], relative_to: u32) -> Result<u32> {
let trimmed = raw.trim();
if trimmed.is_empty() {
anyhow::bail!("version reference must not be empty");
}
if versions.is_empty() {
anyhow::bail!("page has no versions");
}
if trimmed.eq_ignore_ascii_case("latest") {
return Ok(versions[0].number);
}
if trimmed.eq_ignore_ascii_case("previous") {
return offset_from(relative_to, 1, versions);
}
if let Some(rest) = trimmed
.strip_prefix("v-")
.or_else(|| trimmed.strip_prefix("V-"))
{
let offset: u32 = rest.parse().with_context(|| {
format!("Invalid relative version offset \"{trimmed}\"; expected v-N with N > 0")
})?;
if offset == 0 {
anyhow::bail!("Relative version offset must be > 0; got \"{trimmed}\"");
}
return offset_from(relative_to, offset, versions);
}
if trimmed.chars().all(|c| c.is_ascii_digit()) {
let n: u32 = trimmed
.parse()
.with_context(|| format!("Invalid version number \"{trimmed}\""))?;
if !versions.iter().any(|v| v.number == n) {
anyhow::bail!("Version {n} not found in page history");
}
return Ok(n);
}
if trimmed.contains('-') || trimmed.contains('T') {
for v in versions {
if !v.created_at.is_empty() && v.created_at.as_str() <= trimmed {
return Ok(v.number);
}
}
anyhow::bail!("No version found at or before \"{trimmed}\"");
}
anyhow::bail!(
"Could not parse \"{trimmed}\" as a version reference; expected \
\"latest\", \"previous\", \"v-N\", a numeric version (e.g. \"5\"), \
or an ISO 8601 date (e.g. \"2026-01-01T00:00:00Z\")"
)
}
fn offset_from(anchor: u32, offset: u32, versions: &[PageVersion]) -> Result<u32> {
if anchor <= offset {
anyhow::bail!(
"Cannot resolve v-{offset} relative to version {anchor}: out of range \
(would be {} or lower)",
i64::from(anchor) - i64::from(offset)
);
}
let target = anchor - offset;
if !versions.iter().any(|v| v.number == target) {
anyhow::bail!(
"Version {target} not found in page history \
(resolved from anchor {anchor} - {offset})"
);
}
Ok(target)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn confluence_api_backend_name() {
let client =
AtlassianClient::new("https://org.atlassian.net", "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
assert_eq!(api.backend_name(), "confluence");
}
#[test]
fn confluence_page_response_deserialization() {
let json = r#"{
"id": "12345",
"title": "Test Page",
"status": "current",
"spaceId": "98765",
"version": {"number": 3},
"body": {
"atlas_doc_format": {
"value": "{\"version\":1,\"type\":\"doc\",\"content\":[]}"
}
},
"parentId": "11111"
}"#;
let page: ConfluencePageResponse = serde_json::from_str(json).unwrap();
assert_eq!(page.id, "12345");
assert_eq!(page.title, "Test Page");
assert_eq!(page.status, "current");
assert_eq!(page.space_id, "98765");
assert_eq!(page.version.unwrap().number, 3);
assert_eq!(page.parent_id.as_deref(), Some("11111"));
let body = page.body.unwrap();
let atlas_doc = body.atlas_doc_format.unwrap();
let adf: serde_json::Value = serde_json::from_str(&atlas_doc.value).unwrap();
assert_eq!(adf["version"], 1);
assert_eq!(adf["type"], "doc");
}
#[test]
fn confluence_page_response_minimal() {
let json = r#"{
"id": "99",
"title": "Minimal",
"status": "draft",
"spaceId": "1"
}"#;
let page: ConfluencePageResponse = serde_json::from_str(json).unwrap();
assert_eq!(page.id, "99");
assert!(page.version.is_none());
assert!(page.body.is_none());
assert!(page.parent_id.is_none());
}
#[test]
fn confluence_update_request_serialization() {
let req = ConfluenceUpdateRequest {
id: "12345".to_string(),
status: "current".to_string(),
title: "Updated Title".to_string(),
body: ConfluenceUpdateBody {
representation: "atlas_doc_format".to_string(),
value: r#"{"version":1,"type":"doc","content":[]}"#.to_string(),
},
version: ConfluenceUpdateVersion {
number: 4,
message: None,
},
};
let json = serde_json::to_value(&req).unwrap();
assert_eq!(json["id"], "12345");
assert_eq!(json["status"], "current");
assert_eq!(json["title"], "Updated Title");
assert_eq!(json["body"]["representation"], "atlas_doc_format");
assert_eq!(json["version"]["number"], 4);
}
#[test]
fn confluence_update_version_with_message() {
let req = ConfluenceUpdateRequest {
id: "1".to_string(),
status: "current".to_string(),
title: "T".to_string(),
body: ConfluenceUpdateBody {
representation: "atlas_doc_format".to_string(),
value: "{}".to_string(),
},
version: ConfluenceUpdateVersion {
number: 2,
message: Some("Updated via API".to_string()),
},
};
let json = serde_json::to_value(&req).unwrap();
assert_eq!(json["version"]["message"], "Updated via API");
}
#[test]
fn confluence_space_response_deserialization() {
let json = r#"{"key": "ENG"}"#;
let space: ConfluenceSpaceResponse = serde_json::from_str(json).unwrap();
assert_eq!(space.key, "ENG");
}
fn adf_with_panel_expand() -> AdfDocument {
use crate::atlassian::adf::AdfNode;
AdfDocument {
version: 1,
doc_type: "doc".to_string(),
content: vec![AdfNode {
node_type: "panel".to_string(),
attrs: Some(serde_json::json!({"panelType": "info"})),
content: Some(vec![AdfNode {
node_type: "expand".to_string(),
attrs: Some(serde_json::json!({"title": "details"})),
content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("x")])]),
text: None,
marks: None,
local_id: None,
parameters: None,
}]),
text: None,
marks: None,
local_id: None,
parameters: None,
}],
}
}
async fn setup_confluence_mock() -> (wiremock::MockServer, ConfluenceApi) {
let server = wiremock::MockServer::start().await;
let page_json = serde_json::json!({
"id": "12345",
"title": "Test Page",
"status": "current",
"spaceId": "98765",
"version": {"number": 3},
"body": {
"atlas_doc_format": {
"value": "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"Hello\"}]}]}"
}
},
"parentId": "11111"
});
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&page_json))
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/spaces/98765"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"key": "ENG"})),
)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
(server, api)
}
#[tokio::test]
async fn get_content_success() {
use crate::atlassian::api::{AtlassianApi, ContentMetadata};
let (_server, api) = setup_confluence_mock().await;
let item = api.get_content("12345").await.unwrap();
assert_eq!(item.id, "12345");
assert_eq!(item.title, "Test Page");
assert!(item.body_adf.is_some());
match &item.metadata {
ContentMetadata::Confluence {
space_key,
status,
version,
parent_id,
} => {
assert_eq!(space_key, "ENG");
assert_eq!(status.as_deref(), Some("current"));
assert_eq!(*version, Some(3));
assert_eq!(parent_id.as_deref(), Some("11111"));
}
ContentMetadata::Jira { .. } => panic!("Expected Confluence metadata"),
}
}
#[tokio::test]
async fn get_content_api_error() {
use crate::atlassian::api::AtlassianApi;
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/pages/99999"))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let err = api.get_content("99999").await.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[tokio::test]
async fn get_content_no_body() {
use crate::atlassian::api::AtlassianApi;
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/pages/55555"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "55555",
"title": "No Body",
"status": "draft",
"spaceId": "11111"
})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/spaces/11111"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"key": "DEV"})),
)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let item = api.get_content("55555").await.unwrap();
assert!(item.body_adf.is_none());
}
#[tokio::test]
async fn update_content_success() {
use crate::atlassian::api::AtlassianApi;
let (server, api) = setup_confluence_mock().await;
wiremock::Mock::given(wiremock::matchers::method("PUT"))
.and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
.respond_with(wiremock::ResponseTemplate::new(200))
.mount(&server)
.await;
let adf = ValidatedAdfDocument::empty();
let result = api.update_content("12345", &adf, Some("New Title")).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn update_content_api_error() {
use crate::atlassian::api::AtlassianApi;
let (server, api) = setup_confluence_mock().await;
wiremock::Mock::given(wiremock::matchers::method("PUT"))
.and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
.respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
.mount(&server)
.await;
let adf = ValidatedAdfDocument::empty();
let err = api.update_content("12345", &adf, None).await.unwrap_err();
assert!(err.to_string().contains("403"));
}
#[tokio::test]
async fn update_content_500_with_panel_expand_diagnoses() {
use crate::atlassian::api::AtlassianApi;
let (server, api) = setup_confluence_mock().await;
wiremock::Mock::given(wiremock::matchers::method("PUT"))
.and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
.respond_with(wiremock::ResponseTemplate::new(500).set_body_string(
"{\"errors\":[{\"status\":500,\"code\":\"INTERNAL_SERVER_ERROR\"}]}",
))
.mount(&server)
.await;
let adf = ValidatedAdfDocument::trust(adf_with_panel_expand());
let err = api.update_content("12345", &adf, None).await.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("Confluence API returned HTTP 500 (Internal Server Error)"),
"missing 500 header in: {msg}"
);
assert!(
msg.contains("Diagnosis:"),
"missing Diagnosis line in: {msg}"
);
assert!(msg.contains("`expand`"), "missing child name in: {msg}");
assert!(msg.contains("`panel`"), "missing parent name in: {msg}");
assert!(msg.contains("Hint:"), "missing Hint line in: {msg}");
assert!(
!msg.contains("INTERNAL_SERVER_ERROR"),
"raw response body should not be in user-facing message: {msg}"
);
}
#[tokio::test]
async fn update_content_500_without_violation_falls_back() {
use crate::atlassian::api::AtlassianApi;
let (server, api) = setup_confluence_mock().await;
wiremock::Mock::given(wiremock::matchers::method("PUT"))
.and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
.respond_with(
wiremock::ResponseTemplate::new(500).set_body_string("Internal Server Error"),
)
.mount(&server)
.await;
let adf = ValidatedAdfDocument::empty();
let err = api.update_content("12345", &adf, None).await.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("HTTP 500"), "missing status in: {msg}");
assert!(
msg.contains("Internal Server Error"),
"fallback should include raw body: {msg}"
);
assert!(
!msg.contains("Diagnosis:"),
"fallback should not include diagnosis: {msg}"
);
}
#[tokio::test]
async fn verify_auth_success() {
use crate::atlassian::api::AtlassianApi;
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/rest/api/3/myself"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"displayName": "Alice",
"accountId": "abc123"
})),
)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let name = api.verify_auth().await.unwrap();
assert_eq!(name, "Alice");
}
#[tokio::test]
async fn resolve_space_id_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/spaces"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"results": [{"id": "98765"}]})),
)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let id = api.resolve_space_id("ENG").await.unwrap();
assert_eq!(id, "98765");
}
#[tokio::test]
async fn resolve_space_id_not_found() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/spaces"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"results": []})),
)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let err = api.resolve_space_id("NOPE").await.unwrap_err();
assert!(err.to_string().contains("not found"));
}
#[tokio::test]
async fn resolve_space_id_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/spaces"))
.respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let err = api.resolve_space_id("ENG").await.unwrap_err();
assert!(err.to_string().contains("403"));
}
#[tokio::test]
async fn create_page_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/spaces"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"results": [{"id": "98765"}]})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/wiki/api/v2/pages"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"id": "54321"})),
)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let adf = ValidatedAdfDocument::empty();
let id = api
.create_page("ENG", "New Page", &adf, None)
.await
.unwrap();
assert_eq!(id, "54321");
}
#[tokio::test]
async fn create_page_with_parent() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/spaces"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"results": [{"id": "98765"}]})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/wiki/api/v2/pages"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"id": "54322"})),
)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let adf = ValidatedAdfDocument::empty();
let id = api
.create_page("ENG", "Child Page", &adf, Some("11111"))
.await
.unwrap();
assert_eq!(id, "54322");
}
#[tokio::test]
async fn create_page_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/spaces"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"results": [{"id": "98765"}]})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/wiki/api/v2/pages"))
.respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Bad Request"))
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let adf = ValidatedAdfDocument::empty();
let err = api
.create_page("ENG", "Fail", &adf, None)
.await
.unwrap_err();
assert!(err.to_string().contains("400"));
}
#[tokio::test]
async fn create_page_500_with_panel_expand_diagnoses() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/spaces"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"results": [{"id": "98765"}]})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/wiki/api/v2/pages"))
.respond_with(
wiremock::ResponseTemplate::new(500).set_body_string("Internal Server Error"),
)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let adf = ValidatedAdfDocument::trust(adf_with_panel_expand());
let err = api.create_page("ENG", "Bad", &adf, None).await.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("HTTP 500"), "missing status in: {msg}");
assert!(
msg.contains("Diagnosis:"),
"missing Diagnosis line in: {msg}"
);
assert!(msg.contains("`expand`"), "missing child name in: {msg}");
assert!(msg.contains("`panel`"), "missing parent name in: {msg}");
assert!(msg.contains("Hint:"), "missing Hint line in: {msg}");
}
#[tokio::test]
async fn delete_page_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("DELETE"))
.and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
.respond_with(wiremock::ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let result = api.delete_page("12345", false).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn delete_page_with_purge() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("DELETE"))
.and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
.and(wiremock::matchers::query_param("purge", "true"))
.respond_with(wiremock::ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let result = api.delete_page("12345", true).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn delete_page_not_found_hints_permissions() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("DELETE"))
.and(wiremock::matchers::path("/wiki/api/v2/pages/99999"))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let err = api.delete_page("99999", false).await.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("not found or insufficient permissions"));
assert!(msg.contains("Space Settings"));
}
#[tokio::test]
async fn delete_page_forbidden() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("DELETE"))
.and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
.respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let err = api.delete_page("12345", false).await.unwrap_err();
assert!(err.to_string().contains("403"));
}
#[test]
fn move_position_as_str() {
assert_eq!(MovePosition::Append.as_str(), "append");
assert_eq!(MovePosition::Before.as_str(), "before");
assert_eq!(MovePosition::After.as_str(), "after");
}
async fn mount_ancestor_fetch(server: &wiremock::MockServer, id: &str, parent_id: &str) {
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(format!("/wiki/api/v2/pages/{id}")))
.and(wiremock::matchers::query_param("include-ancestors", "true"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": id,
"title": "Moved Page",
"status": "current",
"spaceId": "98765",
"parentId": parent_id,
"ancestors": [
{"id": "10"},
{"id": parent_id}
]
})),
)
.mount(server)
.await;
}
#[tokio::test]
async fn move_page_append_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("PUT"))
.and(wiremock::matchers::path(
"/wiki/rest/api/content/12345/move/append/456",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"pageId": "12345"})),
)
.expect(1)
.mount(&server)
.await;
mount_ancestor_fetch(&server, "12345", "456").await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let moved = api
.move_page("12345", "456", MovePosition::Append)
.await
.unwrap();
assert_eq!(moved.id, "12345");
assert_eq!(moved.title, "Moved Page");
assert_eq!(moved.parent_id.as_deref(), Some("456"));
assert_eq!(moved.ancestors, vec!["10".to_string(), "456".to_string()]);
}
#[tokio::test]
async fn move_page_before_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("PUT"))
.and(wiremock::matchers::path(
"/wiki/rest/api/content/12345/move/before/456",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"pageId": "12345"})),
)
.expect(1)
.mount(&server)
.await;
mount_ancestor_fetch(&server, "12345", "789").await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let moved = api
.move_page("12345", "456", MovePosition::Before)
.await
.unwrap();
assert_eq!(moved.parent_id.as_deref(), Some("789"));
}
#[tokio::test]
async fn move_page_after_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("PUT"))
.and(wiremock::matchers::path(
"/wiki/rest/api/content/12345/move/after/456",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"pageId": "12345"})),
)
.expect(1)
.mount(&server)
.await;
mount_ancestor_fetch(&server, "12345", "789").await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let moved = api
.move_page("12345", "456", MovePosition::After)
.await
.unwrap();
assert_eq!(moved.id, "12345");
}
#[tokio::test]
async fn move_page_forbidden_surfaces_reason() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("PUT"))
.and(wiremock::matchers::path(
"/wiki/rest/api/content/12345/move/append/456",
))
.respond_with(
wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
"errors": [{"detail": "User cannot move page"}]
})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let err = api
.move_page("12345", "456", MovePosition::Append)
.await
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("insufficient permissions"));
assert!(msg.contains("User cannot move page"));
}
#[tokio::test]
async fn move_page_not_found() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("PUT"))
.and(wiremock::matchers::path(
"/wiki/rest/api/content/99999/move/append/456",
))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Page not found"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let err = api
.move_page("99999", "456", MovePosition::Append)
.await
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("not found"));
assert!(msg.contains("Page not found"));
}
#[tokio::test]
async fn move_page_other_error_falls_through_to_generic() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("PUT"))
.and(wiremock::matchers::path(
"/wiki/rest/api/content/12345/move/append/456",
))
.respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let err = api
.move_page("12345", "456", MovePosition::Append)
.await
.unwrap_err();
assert!(err.to_string().contains("500"));
}
#[tokio::test]
async fn move_page_ancestor_fetch_failure_is_propagated() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("PUT"))
.and(wiremock::matchers::path(
"/wiki/rest/api/content/12345/move/append/456",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"pageId": "12345"})),
)
.expect(1)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
.and(wiremock::matchers::query_param("include-ancestors", "true"))
.respond_with(wiremock::ResponseTemplate::new(500).set_body_string("ancestor boom"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let err = api
.move_page("12345", "456", MovePosition::Append)
.await
.unwrap_err();
assert!(err.to_string().contains("500"));
}
#[tokio::test]
async fn get_children_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/wiki/rest/api/content/12345/child/page",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [
{"id": "111", "title": "Child One"},
{"id": "222", "title": "Child Two"}
]
})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let children = api.get_children("12345").await.unwrap();
assert_eq!(children.len(), 2);
assert_eq!(children[0].id, "111");
assert_eq!(children[0].title, "Child One");
assert_eq!(children[1].id, "222");
}
#[tokio::test]
async fn get_children_empty() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/wiki/rest/api/content/12345/child/page",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"results": []})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let children = api.get_children("12345").await.unwrap();
assert!(children.is_empty());
}
#[tokio::test]
async fn get_children_pagination() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/wiki/rest/api/content/12345/child/page",
))
.and(wiremock::matchers::query_param_is_missing("start"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [{"id": "111", "title": "First", "status": "current"}],
"_links": {
"next": "/wiki/rest/api/content/12345/child/page?limit=50&start=50"
}
})),
)
.expect(1)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/wiki/rest/api/content/12345/child/page",
))
.and(wiremock::matchers::query_param("start", "50"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [{"id": "222", "title": "Second", "status": "current"}]
})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let children = api.get_children("12345").await.unwrap();
assert_eq!(children.len(), 2);
assert_eq!(children[0].status, "current");
assert_eq!(children[0].parent_id.as_deref(), Some("12345"));
}
#[tokio::test]
async fn get_space_root_pages_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/spaces/98765/pages"))
.and(wiremock::matchers::query_param("depth", "root"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [
{"id": "111", "title": "Top One", "status": "current"},
{"id": "222", "title": "Top Two", "status": "draft", "parentId": null}
]
})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let pages = api.get_space_root_pages("98765").await.unwrap();
assert_eq!(pages.len(), 2);
assert_eq!(pages[0].id, "111");
assert_eq!(pages[0].status, "current");
assert_eq!(pages[1].status, "draft");
}
#[tokio::test]
async fn get_space_root_pages_empty() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/spaces/98765/pages"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"results": []})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let pages = api.get_space_root_pages("98765").await.unwrap();
assert!(pages.is_empty());
}
#[tokio::test]
async fn get_space_root_pages_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/spaces/99999/pages"))
.respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let err = api.get_space_root_pages("99999").await.unwrap_err();
assert!(err.to_string().contains("403"));
}
#[tokio::test]
async fn get_space_root_pages_pagination() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/spaces/98765/pages"))
.and(wiremock::matchers::query_param_is_missing("cursor"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [{"id": "111", "title": "A", "status": "current"}],
"_links": {
"next": "/wiki/api/v2/spaces/98765/pages?depth=root&cursor=page2"
}
})),
)
.expect(1)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/spaces/98765/pages"))
.and(wiremock::matchers::query_param("cursor", "page2"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [{"id": "222", "title": "B", "status": "current"}]
})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let pages = api.get_space_root_pages("98765").await.unwrap();
assert_eq!(pages.len(), 2);
assert_eq!(pages[0].id, "111");
assert_eq!(pages[1].id, "222");
}
#[tokio::test]
async fn get_children_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/wiki/rest/api/content/99999/child/page",
))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let err = api.get_children("99999").await.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[tokio::test]
async fn resolve_space_key_fallback_on_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/spaces/unknown"))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let key = api.resolve_space_key("unknown").await.unwrap();
assert_eq!(key, "unknown");
}
#[tokio::test]
async fn get_page_comments_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/wiki/api/v2/pages/12345/footer-comments",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [
{
"id": "100",
"version": {
"authorId": "user-abc",
"createdAt": "2026-04-01T10:00:00.000Z"
},
"body": {
"atlas_doc_format": {
"value": "{\"version\":1,\"type\":\"doc\",\"content\":[]}"
}
}
},
{
"id": "101",
"version": {
"authorId": "user-def",
"createdAt": "2026-04-02T14:00:00.000Z"
}
}
]
})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let comments = api.get_page_comments("12345").await.unwrap();
assert_eq!(comments.len(), 2);
assert_eq!(comments[0].id, "100");
assert_eq!(comments[0].author, "user-abc");
assert!(comments[0].body_adf.is_some());
assert_eq!(comments[1].id, "101");
assert!(comments[1].body_adf.is_none());
}
#[tokio::test]
async fn get_page_comments_malformed_adf_body() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/wiki/api/v2/pages/12345/footer-comments",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [
{
"id": "100",
"version": {
"authorId": "user-abc",
"createdAt": "2026-04-01T10:00:00.000Z"
},
"body": {
"atlas_doc_format": {
"value": "{ invalid json }"
}
}
}
]
})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let comments = api.get_page_comments("12345").await.unwrap();
assert_eq!(comments.len(), 1);
assert_eq!(comments[0].id, "100");
assert!(comments[0].body_adf.is_none());
}
#[tokio::test]
async fn get_page_comments_missing_version() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/wiki/api/v2/pages/12345/footer-comments",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [
{
"id": "100"
}
]
})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let comments = api.get_page_comments("12345").await.unwrap();
assert_eq!(comments.len(), 1);
assert_eq!(comments[0].author, "");
assert_eq!(comments[0].created, "");
assert!(comments[0].body_adf.is_none());
}
#[tokio::test]
async fn get_page_comments_empty() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/wiki/api/v2/pages/12345/footer-comments",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"results": []})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let comments = api.get_page_comments("12345").await.unwrap();
assert!(comments.is_empty());
}
#[tokio::test]
async fn get_page_comments_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/wiki/api/v2/pages/99999/footer-comments",
))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let err = api.get_page_comments("99999").await.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[tokio::test]
async fn get_page_comments_with_pagination() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/wiki/api/v2/pages/12345/footer-comments",
))
.and(wiremock::matchers::query_param_is_missing("cursor"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [
{
"id": "100",
"version": {
"authorId": "user-abc",
"createdAt": "2026-04-01T10:00:00.000Z"
},
"body": {
"atlas_doc_format": {
"value": "{\"version\":1,\"type\":\"doc\",\"content\":[]}"
}
}
}
],
"_links": {
"next": "/wiki/api/v2/pages/12345/footer-comments?body-format=atlas_doc_format&cursor=page2"
}
})),
)
.expect(1)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/wiki/api/v2/pages/12345/footer-comments",
))
.and(wiremock::matchers::query_param("cursor", "page2"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [
{
"id": "101",
"version": {
"authorId": "user-def",
"createdAt": "2026-04-02T14:00:00.000Z"
}
}
]
})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let comments = api.get_page_comments("12345").await.unwrap();
assert_eq!(comments.len(), 2);
assert_eq!(comments[0].id, "100");
assert_eq!(comments[0].author, "user-abc");
assert!(comments[0].body_adf.is_some());
assert_eq!(comments[1].id, "101");
assert_eq!(comments[1].author, "user-def");
}
#[tokio::test]
async fn get_page_comments_pagination_stops_on_empty_page() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/wiki/api/v2/pages/12345/footer-comments",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [],
"_links": {
"next": "/wiki/api/v2/pages/12345/footer-comments?cursor=loop"
}
})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let comments = api.get_page_comments("12345").await.unwrap();
assert!(comments.is_empty());
}
#[tokio::test]
async fn add_page_comment_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/wiki/api/v2/footer-comments"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"id": "200"})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let adf = ValidatedAdfDocument::empty();
let result = api.add_page_comment("12345", &adf).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn add_page_comment_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/wiki/api/v2/footer-comments"))
.respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let adf = ValidatedAdfDocument::empty();
let err = api.add_page_comment("12345", &adf).await.unwrap_err();
assert!(err.to_string().contains("403"));
}
#[tokio::test]
async fn add_page_comment_500_with_panel_expand_diagnoses() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/wiki/api/v2/footer-comments"))
.respond_with(
wiremock::ResponseTemplate::new(500).set_body_string("Internal Server Error"),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let adf = ValidatedAdfDocument::trust(adf_with_panel_expand());
let err = api.add_page_comment("12345", &adf).await.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("HTTP 500"), "missing status in: {msg}");
assert!(
msg.contains("Diagnosis:"),
"missing Diagnosis line in: {msg}"
);
assert!(msg.contains("`expand`"), "missing child name in: {msg}");
assert!(msg.contains("`panel`"), "missing parent name in: {msg}");
assert!(msg.contains("Hint:"), "missing Hint line in: {msg}");
}
#[tokio::test]
async fn get_labels_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/pages/12345/labels"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [
{"id": "1", "name": "architecture", "prefix": "global"},
{"id": "2", "name": "draft", "prefix": "global"}
]
})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let labels = api.get_labels("12345").await.unwrap();
assert_eq!(labels.len(), 2);
assert_eq!(labels[0].name, "architecture");
assert_eq!(labels[0].prefix, "global");
assert_eq!(labels[1].name, "draft");
}
#[tokio::test]
async fn get_labels_with_pagination() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/pages/12345/labels"))
.and(wiremock::matchers::query_param_is_missing("cursor"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [
{"id": "1", "name": "architecture", "prefix": "global"}
],
"_links": {
"next": "/wiki/api/v2/pages/12345/labels?cursor=page2"
}
})),
)
.expect(1)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/pages/12345/labels"))
.and(wiremock::matchers::query_param("cursor", "page2"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [
{"id": "2", "name": "draft", "prefix": "global"}
]
})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let labels = api.get_labels("12345").await.unwrap();
assert_eq!(labels.len(), 2);
assert_eq!(labels[0].name, "architecture");
assert_eq!(labels[1].name, "draft");
}
#[tokio::test]
async fn get_labels_empty() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/pages/12345/labels"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"results": []})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let labels = api.get_labels("12345").await.unwrap();
assert!(labels.is_empty());
}
#[tokio::test]
async fn get_labels_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/pages/99999/labels"))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let err = api.get_labels("99999").await.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[tokio::test]
async fn add_labels_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/wiki/rest/api/content/12345/label",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [
{"prefix": "global", "name": "architecture", "id": "1"},
{"prefix": "global", "name": "draft", "id": "2"}
]
})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let result = api
.add_labels("12345", &["architecture".to_string(), "draft".to_string()])
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn add_labels_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/wiki/rest/api/content/99999/label",
))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let err = api
.add_labels("99999", &["test".to_string()])
.await
.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[tokio::test]
async fn remove_label_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("DELETE"))
.and(wiremock::matchers::path(
"/wiki/rest/api/content/12345/label/architecture",
))
.respond_with(wiremock::ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let result = api.remove_label("12345", "architecture").await;
assert!(result.is_ok());
}
#[tokio::test]
async fn remove_label_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("DELETE"))
.and(wiremock::matchers::path(
"/wiki/rest/api/content/99999/label/missing",
))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let err = api.remove_label("99999", "missing").await.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[test]
fn confluence_label_entry_deserialization() {
let json = r#"{"id": "1", "name": "architecture", "prefix": "global"}"#;
let entry: ConfluenceLabelEntry = serde_json::from_str(json).unwrap();
assert_eq!(entry.id, "1");
assert_eq!(entry.name, "architecture");
assert_eq!(entry.prefix, "global");
}
#[test]
fn confluence_add_label_entry_serialization() {
let entry = ConfluenceAddLabelEntry {
prefix: "global".to_string(),
name: "test".to_string(),
};
let json = serde_json::to_value(&entry).unwrap();
assert_eq!(json["prefix"], "global");
assert_eq!(json["name"], "test");
}
#[test]
fn since_filter_parse_numeric() {
assert_eq!(SinceFilter::parse("5").unwrap(), SinceFilter::Version(5));
assert_eq!(SinceFilter::parse("0").unwrap(), SinceFilter::Version(0));
}
#[test]
fn since_filter_parse_iso_date() {
let f = SinceFilter::parse("2026-01-01T00:00:00Z").unwrap();
assert_eq!(
f,
SinceFilter::CreatedAt("2026-01-01T00:00:00Z".to_string())
);
}
#[test]
fn extract_cursor_extracts_value() {
let next = "/wiki/api/v2/pages/12345/attachments?cursor=abc123&limit=25";
assert_eq!(extract_cursor_from_next(next), Some("abc123".to_string()));
}
#[test]
fn extract_cursor_returns_none_when_absent() {
assert_eq!(
extract_cursor_from_next("/wiki/api/v2/pages/12345/attachments?limit=25"),
None
);
}
#[test]
fn since_filter_parse_iso_date_no_time() {
let f = SinceFilter::parse("2026-01-01").unwrap();
assert_eq!(f, SinceFilter::CreatedAt("2026-01-01".to_string()));
}
#[test]
fn since_filter_parse_trims_whitespace() {
assert_eq!(
SinceFilter::parse(" 7 ").unwrap(),
SinceFilter::Version(7)
);
}
#[test]
fn extract_cursor_decodes_percent_encoded() {
assert_eq!(
extract_cursor_from_next("/wiki/api/v2/pages/1/attachments?cursor=foo%3Dbar"),
Some("foo=bar".to_string())
);
}
#[test]
fn since_filter_parse_empty_rejected() {
assert!(SinceFilter::parse("").is_err());
assert!(SinceFilter::parse(" ").is_err());
}
#[test]
fn since_filter_parse_garbage_rejected() {
assert!(SinceFilter::parse("nope").is_err());
}
#[test]
fn since_filter_matches_version() {
let v = PageVersion {
number: 5,
created_at: String::new(),
author_id: String::new(),
message: String::new(),
minor_edit: false,
};
assert!(SinceFilter::Version(5).matches(&v));
assert!(SinceFilter::Version(4).matches(&v));
assert!(!SinceFilter::Version(6).matches(&v));
}
#[test]
fn since_filter_matches_created_at() {
let v = PageVersion {
number: 1,
created_at: "2026-05-01T00:00:00Z".to_string(),
author_id: String::new(),
message: String::new(),
minor_edit: false,
};
assert!(SinceFilter::CreatedAt("2026-04-01T00:00:00Z".to_string()).matches(&v));
assert!(SinceFilter::CreatedAt("2026-05-01T00:00:00Z".to_string()).matches(&v));
assert!(!SinceFilter::CreatedAt("2026-06-01T00:00:00Z".to_string()).matches(&v));
}
#[test]
fn since_filter_created_at_treats_empty_as_too_old() {
let v = PageVersion {
number: 1,
created_at: String::new(),
author_id: String::new(),
message: String::new(),
minor_edit: false,
};
assert!(!SinceFilter::CreatedAt("2026-01-01".to_string()).matches(&v));
}
#[test]
fn version_entry_deserialization_full() {
let json = r#"{
"number": 9,
"createdAt": "2026-05-08T10:23:11Z",
"message": "Updated DB version",
"minorEdit": false,
"authorId": "abc-123"
}"#;
let entry: ConfluenceVersionEntry = serde_json::from_str(json).unwrap();
assert_eq!(entry.number, 9);
assert_eq!(entry.created_at.as_deref(), Some("2026-05-08T10:23:11Z"));
assert_eq!(entry.message.as_deref(), Some("Updated DB version"));
assert_eq!(entry.minor_edit, Some(false));
assert_eq!(entry.author_id.as_deref(), Some("abc-123"));
}
#[test]
fn version_entry_deserialization_sparse() {
let json = r#"{"number": 1}"#;
let entry: ConfluenceVersionEntry = serde_json::from_str(json).unwrap();
assert_eq!(entry.number, 1);
assert!(entry.created_at.is_none());
assert!(entry.message.is_none());
assert!(entry.minor_edit.is_none());
assert!(entry.author_id.is_none());
}
#[tokio::test]
async fn get_page_metadata_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "12345",
"title": "Hello",
"status": "current",
"spaceId": "1",
"version": {"number": 7}
})),
)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
let api = ConfluenceApi::new(client);
let meta = api.get_page_metadata("12345").await.unwrap();
assert_eq!(meta.id, "12345");
assert_eq!(meta.title, "Hello");
assert_eq!(meta.current_version, Some(7));
}
#[tokio::test]
async fn get_page_metadata_no_version() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "12345",
"title": "Hello",
"status": "current",
"spaceId": "1"
})),
)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
let api = ConfluenceApi::new(client);
let meta = api.get_page_metadata("12345").await.unwrap();
assert_eq!(meta.current_version, None);
}
#[tokio::test]
async fn get_page_metadata_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/pages/99999"))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
let api = ConfluenceApi::new(client);
let err = api.get_page_metadata("99999").await.unwrap_err();
assert!(err.to_string().contains("404"));
}
fn version_json(
number: u32,
created: &str,
author: &str,
msg: &str,
minor: bool,
) -> serde_json::Value {
serde_json::json!({
"number": number,
"createdAt": created,
"message": msg,
"minorEdit": minor,
"authorId": author
})
}
#[tokio::test]
async fn list_page_versions_single_page() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/pages/12/versions"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [
version_json(3, "2026-05-08T10:00:00Z", "a", "third", false),
version_json(2, "2026-05-07T10:00:00Z", "b", "second", true),
version_json(1, "2026-05-06T10:00:00Z", "c", "first", false),
]
})),
)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
let api = ConfluenceApi::new(client);
let (versions, truncated) = api.list_page_versions("12", None, 0).await.unwrap();
assert_eq!(versions.len(), 3);
assert!(!truncated);
assert_eq!(versions[0].number, 3);
assert_eq!(versions[0].author_id, "a");
assert_eq!(versions[0].message, "third");
assert!(versions[1].minor_edit);
}
#[tokio::test]
async fn list_page_versions_paginates_until_exhausted() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/pages/12/versions"))
.and(wiremock::matchers::query_param("limit", "100"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [
version_json(4, "2026-05-08T10:00:00Z", "a", "four", false),
version_json(3, "2026-05-07T10:00:00Z", "b", "three", false),
],
"_links": {"next": "/wiki/api/v2/pages/12/versions?cursor=abc"}
})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/pages/12/versions"))
.and(wiremock::matchers::query_param("cursor", "abc"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [
version_json(2, "2026-05-06T10:00:00Z", "c", "two", false),
version_json(1, "2026-05-05T10:00:00Z", "d", "one", false),
]
})),
)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
let api = ConfluenceApi::new(client);
let (versions, truncated) = api.list_page_versions("12", None, 0).await.unwrap();
assert_eq!(
versions.iter().map(|v| v.number).collect::<Vec<_>>(),
vec![4, 3, 2, 1]
);
assert!(!truncated);
}
#[tokio::test]
async fn list_page_versions_limit_truncates() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/pages/12/versions"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [
version_json(5, "2026-05-09T10:00:00Z", "a", "five", false),
version_json(4, "2026-05-08T10:00:00Z", "b", "four", false),
version_json(3, "2026-05-07T10:00:00Z", "c", "three", false),
]
})),
)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
let api = ConfluenceApi::new(client);
let (versions, truncated) = api.list_page_versions("12", None, 2).await.unwrap();
assert_eq!(versions.len(), 2);
assert!(truncated, "limit reached mid-page should mark truncated");
assert_eq!(versions[0].number, 5);
assert_eq!(versions[1].number, 4);
}
#[tokio::test]
async fn list_page_versions_limit_exact_page_with_next_truncated() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/pages/12/versions"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [
version_json(5, "2026-05-09T10:00:00Z", "a", "five", false),
version_json(4, "2026-05-08T10:00:00Z", "b", "four", false),
],
"_links": {"next": "/wiki/api/v2/pages/12/versions?cursor=z"}
})),
)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
let api = ConfluenceApi::new(client);
let (versions, truncated) = api.list_page_versions("12", None, 2).await.unwrap();
assert_eq!(versions.len(), 2);
assert!(truncated);
}
#[tokio::test]
async fn list_page_versions_since_numeric_filter() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/pages/12/versions"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [
version_json(5, "2026-05-09T10:00:00Z", "a", "5", false),
version_json(4, "2026-05-08T10:00:00Z", "b", "4", false),
version_json(3, "2026-05-07T10:00:00Z", "c", "3", false),
version_json(2, "2026-05-06T10:00:00Z", "d", "2", false),
]
})),
)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
let api = ConfluenceApi::new(client);
let filter = SinceFilter::parse("4").unwrap();
let (versions, truncated) = api
.list_page_versions("12", Some(&filter), 0)
.await
.unwrap();
assert_eq!(
versions.iter().map(|v| v.number).collect::<Vec<_>>(),
vec![5, 4]
);
assert!(!truncated);
}
#[tokio::test]
async fn list_page_versions_since_iso_filter() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/pages/12/versions"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [
version_json(3, "2026-05-08T10:00:00Z", "a", "", false),
version_json(2, "2026-04-01T10:00:00Z", "b", "", false),
version_json(1, "2026-03-01T10:00:00Z", "c", "", false),
]
})),
)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
let api = ConfluenceApi::new(client);
let filter = SinceFilter::parse("2026-05-01").unwrap();
let (versions, truncated) = api
.list_page_versions("12", Some(&filter), 0)
.await
.unwrap();
assert_eq!(
versions.iter().map(|v| v.number).collect::<Vec<_>>(),
vec![3]
);
assert!(!truncated);
}
#[tokio::test]
async fn list_page_versions_since_stops_pagination_early() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/pages/12/versions"))
.and(wiremock::matchers::query_param("limit", "100"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [
version_json(5, "2026-05-09T10:00:00Z", "a", "5", false),
version_json(4, "2026-05-08T10:00:00Z", "b", "4", false),
],
"_links": {"next": "/wiki/api/v2/pages/12/versions?cursor=p2"}
})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/pages/12/versions"))
.and(wiremock::matchers::query_param("cursor", "p2"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [
version_json(3, "2026-05-07T10:00:00Z", "c", "3", false),
version_json(2, "2026-04-30T10:00:00Z", "d", "2", false),
]
})),
)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
let api = ConfluenceApi::new(client);
let filter = SinceFilter::parse("2026-05-01").unwrap();
let (versions, truncated) = api
.list_page_versions("12", Some(&filter), 0)
.await
.unwrap();
assert_eq!(
versions.iter().map(|v| v.number).collect::<Vec<_>>(),
vec![5, 4, 3]
);
assert!(!truncated, "since cutoff is a stop, not a truncation");
}
#[tokio::test]
async fn list_page_versions_tolerates_missing_optional_fields() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/pages/12/versions"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [
{"number": 1}
]
})),
)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
let api = ConfluenceApi::new(client);
let (versions, truncated) = api.list_page_versions("12", None, 0).await.unwrap();
assert_eq!(versions.len(), 1);
assert_eq!(versions[0].number, 1);
assert_eq!(versions[0].created_at, "");
assert_eq!(versions[0].author_id, "");
assert_eq!(versions[0].message, "");
assert!(!versions[0].minor_edit);
assert!(!truncated);
}
#[tokio::test]
async fn list_page_versions_empty_result() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/pages/12/versions"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"results": []})),
)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
let api = ConfluenceApi::new(client);
let (versions, truncated) = api.list_page_versions("12", None, 0).await.unwrap();
assert!(versions.is_empty());
assert!(!truncated);
}
#[tokio::test]
async fn list_page_versions_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/wiki/api/v2/pages/99999/versions",
))
.respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
let api = ConfluenceApi::new(client);
let err = api.list_page_versions("99999", None, 0).await.unwrap_err();
assert!(err.to_string().contains("403"));
}
#[tokio::test]
async fn list_page_versions_uses_limit_as_page_size_when_small() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/pages/12/versions"))
.and(wiremock::matchers::query_param("limit", "5"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [
version_json(1, "2026-05-01T00:00:00Z", "a", "", false),
]
})),
)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
let api = ConfluenceApi::new(client);
let (versions, _) = api.list_page_versions("12", None, 5).await.unwrap();
assert_eq!(versions.len(), 1);
}
#[test]
fn urlencoding_escapes_reserved_chars() {
assert_eq!(urlencoding("a=b&c+d %e#"), "a%3Db%26c%2Bd%20%25e%23");
}
#[tokio::test]
async fn upload_attachment_success() {
use tempfile::NamedTempFile;
use tokio::io::AsyncWriteExt;
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/wiki/api/v2/pages/12345/attachments",
))
.and(wiremock::matchers::header("X-Atlassian-Token", "no-check"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [{
"id": "att-1",
"title": "hello.txt",
"mediaType": "text/plain",
"fileSize": 13,
"downloadLink": "/download/att-1",
"version": {"number": 1},
"pageId": "12345",
"fileId": "f-1"
}]
})),
)
.expect(1)
.mount(&server)
.await;
let mut tmp = tokio::fs::File::from_std(NamedTempFile::new().unwrap().into_file());
tmp.write_all(b"hello, world!").await.unwrap();
tmp.flush().await.unwrap();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("hello.txt");
tokio::fs::write(&path, b"hello, world!").await.unwrap();
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let attachment = api
.upload_attachment("12345", &path, None, Some("v1"), false)
.await
.unwrap();
assert_eq!(attachment.id, "att-1");
assert_eq!(attachment.title, "hello.txt");
assert_eq!(attachment.media_type.as_deref(), Some("text/plain"));
assert_eq!(attachment.file_size, Some(13));
assert_eq!(attachment.version, Some(1));
}
#[tokio::test]
async fn upload_attachment_page_not_found() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/wiki/api/v2/pages/99999/attachments",
))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
.expect(1)
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("x.bin");
tokio::fs::write(&path, b"x").await.unwrap();
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let err = api
.upload_attachment("99999", &path, None, None, false)
.await
.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[tokio::test]
async fn upload_attachment_too_large() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/wiki/api/v2/pages/12345/attachments",
))
.respond_with(
wiremock::ResponseTemplate::new(413).set_body_string("Request entity too large"),
)
.expect(1)
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("big.bin");
tokio::fs::write(&path, b"x").await.unwrap();
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let err = api
.upload_attachment("12345", &path, None, None, false)
.await
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("413"));
assert!(msg.contains("Request entity too large"));
}
#[tokio::test]
async fn upload_attachment_overrides_filename() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/wiki/api/v2/pages/12345/attachments",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [{"id": "a", "title": "renamed.png"}]
})),
)
.expect(1)
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("source.bin");
tokio::fs::write(&path, b"data").await.unwrap();
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let attachment = api
.upload_attachment("12345", &path, Some("renamed.png"), None, true)
.await
.unwrap();
assert_eq!(attachment.title, "renamed.png");
}
#[tokio::test]
async fn list_attachments_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/wiki/api/v2/pages/12345/attachments",
))
.and(wiremock::matchers::query_param("limit", "25"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [
{"id": "a1", "title": "one.png", "mediaType": "image/png", "fileSize": 100, "version": {"number": 1}},
{"id": "a2", "title": "two.pdf", "mediaType": "application/pdf"}
],
"_links": {"next": "/wiki/api/v2/pages/12345/attachments?cursor=NEXT&limit=25"}
})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let page = api.list_attachments("12345", None, 25).await.unwrap();
assert_eq!(page.results.len(), 2);
assert_eq!(page.results[0].id, "a1");
assert_eq!(page.results[0].file_size, Some(100));
assert_eq!(page.next_cursor.as_deref(), Some("NEXT"));
}
#[tokio::test]
async fn list_attachments_no_more_pages() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/wiki/api/v2/pages/12345/attachments",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": []
})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let page = api.list_attachments("12345", None, 25).await.unwrap();
assert!(page.results.is_empty());
assert!(page.next_cursor.is_none());
}
#[tokio::test]
async fn list_attachments_page_not_found() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/wiki/api/v2/pages/99999/attachments",
))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let err = api.list_attachments("99999", None, 25).await.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[tokio::test]
async fn list_attachments_pagination_round_trip() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/wiki/api/v2/pages/12345/attachments",
))
.and(wiremock::matchers::query_param("cursor", "PAGE2"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [{"id": "a3", "title": "three.bin"}]
})),
)
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let page = api
.list_attachments("12345", Some("PAGE2"), 25)
.await
.unwrap();
assert_eq!(page.results.len(), 1);
assert_eq!(page.results[0].id, "a3");
}
#[tokio::test]
async fn delete_attachment_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("DELETE"))
.and(wiremock::matchers::path("/wiki/api/v2/attachments/att-1"))
.respond_with(wiremock::ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
assert!(api.delete_attachment("att-1", false).await.is_ok());
}
#[tokio::test]
async fn delete_attachment_with_purge() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("DELETE"))
.and(wiremock::matchers::path("/wiki/api/v2/attachments/att-1"))
.and(wiremock::matchers::query_param("purge", "true"))
.respond_with(wiremock::ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
assert!(api.delete_attachment("att-1", true).await.is_ok());
}
#[tokio::test]
async fn delete_attachment_not_found() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("DELETE"))
.and(wiremock::matchers::path("/wiki/api/v2/attachments/missing"))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
.expect(1)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
let err = api.delete_attachment("missing", false).await.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[test]
fn confluence_attachment_serialize_skips_none_fields() {
let attachment = ConfluenceAttachment {
id: "att-1".to_string(),
title: "x.txt".to_string(),
media_type: None,
file_size: None,
download_url: None,
version: None,
page_id: None,
file_id: None,
};
let json = serde_json::to_value(&attachment).unwrap();
assert_eq!(json["id"], "att-1");
assert_eq!(json["title"], "x.txt");
assert!(json.get("media_type").is_none());
assert!(json.get("file_size").is_none());
assert!(json.get("download_url").is_none());
assert!(json.get("version").is_none());
assert!(json.get("page_id").is_none());
assert!(json.get("file_id").is_none());
}
#[test]
fn confluence_attachment_serialize_includes_some_fields() {
let attachment = ConfluenceAttachment {
id: "att-1".to_string(),
title: "x.txt".to_string(),
media_type: Some("text/plain".to_string()),
file_size: Some(42),
download_url: Some("/dl".to_string()),
version: Some(3),
page_id: Some("12345".to_string()),
file_id: Some("f-1".to_string()),
};
let json = serde_json::to_value(&attachment).unwrap();
assert_eq!(json["media_type"], "text/plain");
assert_eq!(json["file_size"], 42);
assert_eq!(json["version"], 3);
}
#[test]
fn confluence_attachment_page_serialize_skips_none_cursor() {
let page = ConfluenceAttachmentPage {
results: vec![],
next_cursor: None,
};
let json = serde_json::to_value(&page).unwrap();
assert!(json.get("next_cursor").is_none());
}
async fn mount_page_version(
server: &wiremock::MockServer,
page_id: &str,
version: u32,
adf_value: &str,
) {
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(format!(
"/wiki/api/v2/pages/{page_id}"
)))
.and(wiremock::matchers::query_param(
"version",
version.to_string(),
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": page_id,
"title": format!("Page {page_id} v{version}"),
"status": "current",
"spaceId": "98",
"version": {"number": version},
"body": {
"atlas_doc_format": {"value": adf_value}
}
})),
)
.mount(server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/spaces/98"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"key": "ENG"})),
)
.mount(server)
.await;
}
#[tokio::test]
async fn get_page_at_version_success() {
use crate::atlassian::api::ContentMetadata;
let server = wiremock::MockServer::start().await;
mount_page_version(
&server,
"12",
3,
r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"v3"}]}]}"#,
)
.await;
let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
let api = ConfluenceApi::new(client);
let item = api.get_page_at_version("12", 3).await.unwrap();
assert_eq!(item.id, "12");
assert_eq!(item.title, "Page 12 v3");
assert!(item.body_adf.is_some());
match item.metadata {
ContentMetadata::Confluence {
space_key, version, ..
} => {
assert_eq!(space_key, "ENG");
assert_eq!(version, Some(3));
}
ContentMetadata::Jira { .. } => panic!("expected Confluence metadata"),
}
}
#[tokio::test]
async fn get_page_at_version_404() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/pages/12"))
.and(wiremock::matchers::query_param("version", "99"))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
let api = ConfluenceApi::new(client);
let err = api.get_page_at_version("12", 99).await.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[tokio::test]
async fn get_page_at_version_no_body() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/pages/12"))
.and(wiremock::matchers::query_param("version", "1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "12",
"title": "Empty",
"status": "current",
"spaceId": "1",
"version": {"number": 1}
})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/spaces/1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({"key": "S"})),
)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
let api = ConfluenceApi::new(client);
let item = api.get_page_at_version("12", 1).await.unwrap();
assert!(item.body_adf.is_none());
}
fn version_at(number: u32, created: &str) -> PageVersion {
PageVersion {
number,
created_at: created.to_string(),
author_id: String::new(),
message: String::new(),
minor_edit: false,
}
}
fn fixture_versions() -> Vec<PageVersion> {
vec![
version_at(5, "2026-05-09T10:00:00Z"),
version_at(4, "2026-05-08T10:00:00Z"),
version_at(3, "2026-05-07T10:00:00Z"),
version_at(2, "2026-05-06T10:00:00Z"),
version_at(1, "2026-05-05T10:00:00Z"),
]
}
#[test]
fn resolve_version_latest() {
let v = fixture_versions();
assert_eq!(resolve_version("latest", &v, 5).unwrap(), 5);
assert_eq!(resolve_version("LATEST", &v, 5).unwrap(), 5);
}
#[test]
fn resolve_version_previous_relative_to_anchor() {
let v = fixture_versions();
assert_eq!(resolve_version("previous", &v, 5).unwrap(), 4);
assert_eq!(resolve_version("previous", &v, 3).unwrap(), 2);
}
#[test]
fn resolve_version_previous_at_first_version_errors() {
let v = fixture_versions();
let err = resolve_version("previous", &v, 1).unwrap_err();
assert!(err.to_string().contains("out of range"));
}
#[test]
fn resolve_version_v_minus_offset() {
let v = fixture_versions();
assert_eq!(resolve_version("v-2", &v, 5).unwrap(), 3);
assert_eq!(resolve_version("V-1", &v, 5).unwrap(), 4);
}
#[test]
fn resolve_version_v_minus_zero_rejected() {
let v = fixture_versions();
let err = resolve_version("v-0", &v, 5).unwrap_err();
assert!(err.to_string().contains("> 0"));
}
#[test]
fn resolve_version_v_minus_too_deep() {
let v = fixture_versions();
let err = resolve_version("v-10", &v, 5).unwrap_err();
assert!(err.to_string().contains("out of range"));
}
#[test]
fn resolve_version_numeric_in_range() {
let v = fixture_versions();
assert_eq!(resolve_version("3", &v, 5).unwrap(), 3);
}
#[test]
fn resolve_version_numeric_not_present() {
let v = fixture_versions();
let err = resolve_version("99", &v, 5).unwrap_err();
assert!(err.to_string().contains("not found"));
}
#[test]
fn resolve_version_iso_picks_latest_at_or_before() {
let v = fixture_versions();
assert_eq!(resolve_version("2026-05-08T11:00:00Z", &v, 5).unwrap(), 4);
assert_eq!(resolve_version("2026-05-09T10:00:00Z", &v, 5).unwrap(), 5);
assert_eq!(resolve_version("2026-05-06", &v, 5).unwrap(), 1);
}
#[test]
fn resolve_version_iso_no_match_errors() {
let v = fixture_versions();
let err = resolve_version("2020-01-01", &v, 5).unwrap_err();
assert!(err.to_string().contains("at or before"));
}
#[test]
fn resolve_version_empty_versions_errors() {
let err = resolve_version("latest", &[], 0).unwrap_err();
assert!(err.to_string().contains("no versions"));
}
#[test]
fn resolve_version_empty_string_rejected() {
let v = fixture_versions();
let err = resolve_version(" ", &v, 5).unwrap_err();
assert!(err.to_string().contains("empty"));
}
#[test]
fn resolve_version_unparseable() {
let v = fixture_versions();
let err = resolve_version("garbage", &v, 5).unwrap_err();
assert!(err.to_string().contains("Could not parse"));
}
#[test]
fn resolve_version_v_minus_with_non_numeric_suffix_rejected() {
let v = fixture_versions();
let err = resolve_version("v-abc", &v, 5).unwrap_err();
assert!(
err.to_string().contains("Invalid relative version offset"),
"got: {err}"
);
}
#[test]
fn resolve_version_v_minus_resolves_to_missing_version_errors() {
let versions = vec![
version_at(5, "2026-05-09T10:00:00Z"),
version_at(4, "2026-05-08T10:00:00Z"),
];
let err = resolve_version("v-2", &versions, 4).unwrap_err();
assert!(err.to_string().contains("not found"), "got: {err}");
}
#[tokio::test]
async fn get_page_at_version_with_body_but_no_atlas_doc_format() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/pages/12"))
.and(wiremock::matchers::query_param("version", "1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "12",
"title": "T",
"status": "current",
"spaceId": "1",
"version": {"number": 1},
"body": { }
})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/spaces/1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({"key": "S"})),
)
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
let api = ConfluenceApi::new(client);
let item = api.get_page_at_version("12", 1).await.unwrap();
assert!(item.body_adf.is_none());
}
}