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