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