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 ConfluenceSpacesSearchResponse {
106 results: Vec<ConfluenceSpaceSearchEntry>,
107}
108
109#[derive(Deserialize)]
110struct ConfluenceSpaceSearchEntry {
111 id: String,
112}
113
114#[derive(Deserialize)]
117struct ConfluenceChildrenResponse {
118 results: Vec<ConfluenceChildEntry>,
119 #[serde(rename = "_links", default)]
120 links: Option<ConfluenceChildrenLinks>,
121}
122
123#[derive(Deserialize)]
124struct ConfluenceChildEntry {
125 id: String,
126 title: String,
127 #[serde(default)]
128 status: Option<String>,
129}
130
131#[derive(Deserialize)]
132struct ConfluenceChildrenLinks {
133 next: Option<String>,
134}
135
136#[derive(Deserialize)]
138struct ConfluenceSpacePagesResponse {
139 results: Vec<ConfluenceSpacePageEntry>,
140 #[serde(rename = "_links", default)]
141 links: Option<ConfluenceChildrenLinks>,
142}
143
144#[derive(Deserialize)]
145struct ConfluenceSpacePageEntry {
146 id: String,
147 title: String,
148 #[serde(default)]
149 status: Option<String>,
150 #[serde(rename = "parentId", default)]
151 parent_id: Option<String>,
152}
153
154#[derive(Debug, Clone, serde::Serialize)]
156pub struct ChildPage {
157 pub id: String,
159 pub title: String,
161 #[serde(default, skip_serializing_if = "String::is_empty")]
163 pub status: String,
164 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub parent_id: Option<String>,
167 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub space_key: Option<String>,
170}
171
172#[derive(Debug, Clone, Serialize)]
176pub struct ConfluenceComment {
177 pub id: String,
179 pub author: String,
181 pub body_adf: Option<serde_json::Value>,
183 pub created: String,
185}
186
187#[derive(Deserialize)]
188struct ConfluenceCommentsResponse {
189 results: Vec<ConfluenceCommentEntry>,
190 #[serde(rename = "_links", default)]
191 links: Option<ConfluenceCommentsLinks>,
192}
193
194#[derive(Deserialize)]
195struct ConfluenceCommentsLinks {
196 next: Option<String>,
197}
198
199#[derive(Deserialize)]
200struct ConfluenceCommentEntry {
201 id: String,
202 #[serde(default)]
203 version: Option<ConfluenceCommentVersion>,
204 #[serde(default)]
205 body: Option<ConfluenceCommentBody>,
206}
207
208#[derive(Deserialize)]
209struct ConfluenceCommentVersion {
210 #[serde(rename = "authorId", default)]
211 author_id: Option<String>,
212 #[serde(rename = "createdAt", default)]
213 created_at: Option<String>,
214}
215
216#[derive(Deserialize)]
217struct ConfluenceCommentBody {
218 atlas_doc_format: Option<ConfluenceAtlasDoc>,
219}
220
221#[derive(Serialize)]
222struct ConfluenceAddCommentRequest {
223 #[serde(rename = "pageId")]
224 page_id: String,
225 body: ConfluenceUpdateBody,
226}
227
228#[derive(Deserialize)]
231struct ConfluenceLabelsResponse {
232 results: Vec<ConfluenceLabelEntry>,
233 #[serde(rename = "_links", default)]
234 links: Option<ConfluenceLabelsLinks>,
235}
236
237#[derive(Deserialize)]
238struct ConfluenceLabelEntry {
239 id: String,
240 name: String,
241 prefix: String,
242}
243
244#[derive(Deserialize)]
245struct ConfluenceLabelsLinks {
246 next: Option<String>,
247}
248
249#[derive(Debug, Clone, Serialize)]
251pub struct ConfluenceLabel {
252 pub id: String,
254 pub name: String,
256 pub prefix: String,
258}
259
260#[derive(Serialize)]
261struct ConfluenceAddLabelEntry {
262 prefix: String,
263 name: String,
264}
265
266#[derive(Deserialize)]
269struct ConfluenceVersionsResponse {
270 results: Vec<ConfluenceVersionEntry>,
271 #[serde(rename = "_links", default)]
272 links: Option<ConfluenceVersionsLinks>,
273}
274
275#[derive(Deserialize)]
276struct ConfluenceVersionEntry {
277 number: u32,
278 #[serde(rename = "createdAt", default)]
279 created_at: Option<String>,
280 #[serde(default)]
281 message: Option<String>,
282 #[serde(rename = "minorEdit", default)]
283 minor_edit: Option<bool>,
284 #[serde(rename = "authorId", default)]
285 author_id: Option<String>,
286}
287
288#[derive(Deserialize)]
289struct ConfluenceVersionsLinks {
290 next: Option<String>,
291}
292
293#[derive(Debug, Clone, Serialize, Deserialize)]
299pub struct PageVersion {
300 pub number: u32,
302 #[serde(default)]
304 pub created_at: String,
305 #[serde(default)]
307 pub author_id: String,
308 #[serde(default)]
310 pub message: String,
311 #[serde(default)]
313 pub minor_edit: bool,
314}
315
316#[derive(Debug, Clone, PartialEq, Eq)]
318pub enum SinceFilter {
319 Version(u32),
321 CreatedAt(String),
325}
326
327impl SinceFilter {
328 pub fn parse(raw: &str) -> Result<Self> {
332 let trimmed = raw.trim();
333 if trimmed.is_empty() {
334 anyhow::bail!("`since` must be a version number or ISO 8601 date");
335 }
336 if trimmed.chars().all(|c| c.is_ascii_digit()) {
337 let n: u32 = trimmed
338 .parse()
339 .with_context(|| format!("Invalid version number \"{trimmed}\""))?;
340 return Ok(Self::Version(n));
341 }
342 if trimmed.contains('-') || trimmed.contains('T') {
343 return Ok(Self::CreatedAt(trimmed.to_string()));
344 }
345 anyhow::bail!(
346 "`since` must be a numeric version (e.g. \"5\") or ISO 8601 date \
347 (e.g. \"2026-01-01T00:00:00Z\"); got \"{trimmed}\""
348 );
349 }
350
351 fn matches(&self, version: &PageVersion) -> bool {
353 match self {
354 Self::Version(min) => version.number >= *min,
355 Self::CreatedAt(min) => {
356 if version.created_at.is_empty() {
357 false
359 } else {
360 version.created_at.as_str() >= min.as_str()
361 }
362 }
363 }
364 }
365}
366
367#[derive(Debug, Clone, Serialize)]
372pub struct PageMetadata {
373 pub id: String,
375 pub title: String,
377 pub current_version: Option<u32>,
379}
380
381#[derive(Deserialize)]
384struct ConfluenceAttachmentsResponse {
385 results: Vec<ConfluenceAttachmentEntry>,
386 #[serde(rename = "_links", default)]
387 links: Option<ConfluenceAttachmentLinks>,
388}
389
390#[derive(Deserialize)]
391struct ConfluenceAttachmentLinks {
392 next: Option<String>,
393}
394
395#[derive(Deserialize)]
396struct ConfluenceAttachmentEntry {
397 id: String,
398 title: String,
399 #[serde(rename = "mediaType", default)]
400 media_type: Option<String>,
401 #[serde(rename = "fileSize", default)]
402 file_size: Option<u64>,
403 #[serde(rename = "downloadLink", default)]
404 download_link: Option<String>,
405 #[serde(default)]
406 version: Option<ConfluenceAttachmentVersion>,
407 #[serde(rename = "pageId", default)]
408 page_id: Option<String>,
409 #[serde(rename = "fileId", default)]
410 file_id: Option<String>,
411}
412
413#[derive(Deserialize)]
414struct ConfluenceAttachmentVersion {
415 number: u32,
416}
417
418#[derive(Debug, Clone, Serialize)]
420pub struct ConfluenceAttachment {
421 pub id: String,
423 pub title: String,
425 #[serde(skip_serializing_if = "Option::is_none")]
427 pub media_type: Option<String>,
428 #[serde(skip_serializing_if = "Option::is_none")]
430 pub file_size: Option<u64>,
431 #[serde(skip_serializing_if = "Option::is_none")]
433 pub download_url: Option<String>,
434 #[serde(skip_serializing_if = "Option::is_none")]
436 pub version: Option<u32>,
437 #[serde(skip_serializing_if = "Option::is_none")]
439 pub page_id: Option<String>,
440 #[serde(skip_serializing_if = "Option::is_none")]
442 pub file_id: Option<String>,
443}
444
445impl From<ConfluenceAttachmentEntry> for ConfluenceAttachment {
446 fn from(e: ConfluenceAttachmentEntry) -> Self {
447 Self {
448 id: e.id,
449 title: e.title,
450 media_type: e.media_type,
451 file_size: e.file_size,
452 download_url: e.download_link,
453 version: e.version.map(|v| v.number),
454 page_id: e.page_id,
455 file_id: e.file_id,
456 }
457 }
458}
459
460#[derive(Debug, Clone, Serialize)]
468pub struct ConfluenceAttachmentPage {
469 pub results: Vec<ConfluenceAttachment>,
471 #[serde(skip_serializing_if = "Option::is_none")]
473 pub next_cursor: Option<String>,
474}
475
476#[derive(Serialize)]
479struct ConfluenceCreateRequest {
480 #[serde(rename = "spaceId")]
481 space_id: String,
482 title: String,
483 body: ConfluenceUpdateBody,
484 #[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
485 parent_id: Option<String>,
486 status: String,
487}
488
489#[derive(Deserialize)]
490struct ConfluenceCreateResponse {
491 id: String,
492}
493
494#[derive(Serialize)]
497struct ConfluenceUpdateRequest {
498 id: String,
499 status: String,
500 title: String,
501 body: ConfluenceUpdateBody,
502 version: ConfluenceUpdateVersion,
503}
504
505#[derive(Serialize)]
506struct ConfluenceUpdateBody {
507 representation: String,
508 value: String,
509}
510
511#[derive(Serialize)]
512struct ConfluenceUpdateVersion {
513 number: u32,
514 message: Option<String>,
515}
516
517#[derive(Debug, Clone, Copy, PartialEq, Eq)]
522pub enum MovePosition {
523 Append,
525 Before,
527 After,
529}
530
531impl MovePosition {
532 pub fn as_str(self) -> &'static str {
534 match self {
535 Self::Append => "append",
536 Self::Before => "before",
537 Self::After => "after",
538 }
539 }
540}
541
542#[derive(Debug, Clone, Serialize)]
544pub struct MovedPage {
545 pub id: String,
547 pub title: String,
549 #[serde(skip_serializing_if = "Option::is_none")]
551 pub parent_id: Option<String>,
552 pub ancestors: Vec<String>,
554}
555
556impl AtlassianApi for ConfluenceApi {
557 fn get_content<'a>(
558 &'a self,
559 id: &'a str,
560 ) -> Pin<Box<dyn Future<Output = Result<ContentItem>> + Send + 'a>> {
561 Box::pin(async move {
562 let url = format!(
563 "{}/wiki/api/v2/pages/{}?body-format=atlas_doc_format",
564 self.client.instance_url(),
565 id
566 );
567
568 let response = self
569 .client
570 .get_json(&url)
571 .await
572 .context("Failed to fetch Confluence page")?;
573
574 if !response.status().is_success() {
575 let status = response.status().as_u16();
576 let body = response.text().await.unwrap_or_default();
577 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
578 }
579
580 let page: ConfluencePageResponse = response
581 .json()
582 .await
583 .context("Failed to parse Confluence page response")?;
584
585 debug!(
586 page_id = page.id,
587 title = page.title,
588 "Fetched Confluence page"
589 );
590
591 let body_adf = if let Some(body) = &page.body {
593 if let Some(atlas_doc) = &body.atlas_doc_format {
594 if tracing::enabled!(tracing::Level::TRACE) {
595 if let Ok(pretty) =
596 serde_json::from_str::<serde_json::Value>(&atlas_doc.value)
597 .and_then(|v| serde_json::to_string_pretty(&v))
598 {
599 tracing::trace!("Original ADF from Confluence:\n{pretty}");
600 }
601 }
602 Some(
603 serde_json::from_str(&atlas_doc.value)
604 .context("Failed to parse ADF from Confluence body")?,
605 )
606 } else {
607 None
608 }
609 } else {
610 None
611 };
612
613 let space_key = self.resolve_space_key(&page.space_id).await?;
615
616 Ok(ContentItem {
617 id: page.id,
618 title: page.title,
619 body_adf,
620 metadata: ContentMetadata::Confluence {
621 space_key,
622 status: Some(page.status),
623 version: page.version.map(|v| v.number),
624 parent_id: page.parent_id,
625 },
626 })
627 })
628 }
629
630 fn update_content<'a>(
631 &'a self,
632 id: &'a str,
633 body_adf: &'a ValidatedAdfDocument,
634 title: Option<&'a str>,
635 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>> {
636 Box::pin(async move {
637 let current = self.get_content(id).await?;
639 let current_version = match ¤t.metadata {
640 ContentMetadata::Confluence { version, .. } => version.unwrap_or(1),
641 ContentMetadata::Jira { .. } => 1,
642 };
643 let current_title = current.title;
644
645 let adf_json =
646 serde_json::to_string(body_adf).context("Failed to serialize ADF document")?;
647
648 debug!(
649 page_id = id,
650 version = current_version + 1,
651 adf_bytes = adf_json.len(),
652 "Updating Confluence page"
653 );
654 if tracing::enabled!(tracing::Level::TRACE) {
655 let pretty = serde_json::to_string_pretty(body_adf)
656 .unwrap_or_else(|e| format!("<serialization error: {e}>"));
657 tracing::trace!("ADF body for update:\n{pretty}");
658 }
659
660 let update = ConfluenceUpdateRequest {
661 id: id.to_string(),
662 status: "current".to_string(),
663 title: title.unwrap_or(¤t_title).to_string(),
664 body: ConfluenceUpdateBody {
665 representation: "atlas_doc_format".to_string(),
666 value: adf_json,
667 },
668 version: ConfluenceUpdateVersion {
669 number: current_version + 1,
670 message: None,
671 },
672 };
673
674 let url = format!("{}/wiki/api/v2/pages/{}", self.client.instance_url(), id);
675
676 let response = self
677 .client
678 .put_json(&url, &update)
679 .await
680 .context("Failed to update Confluence page")?;
681
682 if !response.status().is_success() {
683 let status = response.status().as_u16();
684 let body = response.text().await.unwrap_or_default();
685 debug!(status, body = %body, "Confluence update_content non-success");
686 return Err(confluence_write_error(status, body, body_adf));
687 }
688
689 Ok(())
690 })
691 }
692
693 fn verify_auth<'a>(&'a self) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>> {
694 Box::pin(async move {
696 let user = self.client.get_myself().await?;
697 Ok(user.display_name)
698 })
699 }
700
701 fn backend_name(&self) -> &'static str {
702 "confluence"
703 }
704}
705
706impl ConfluenceApi {
707 pub async fn resolve_space_id(&self, space_key: &str) -> Result<String> {
709 let url = format!(
710 "{}/wiki/api/v2/spaces?keys={}",
711 self.client.instance_url(),
712 space_key
713 );
714
715 let response = self
716 .client
717 .get_json(&url)
718 .await
719 .context("Failed to search Confluence spaces")?;
720
721 if !response.status().is_success() {
722 let status = response.status().as_u16();
723 let body = response.text().await.unwrap_or_default();
724 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
725 }
726
727 let resp: ConfluenceSpacesSearchResponse = response
728 .json()
729 .await
730 .context("Failed to parse Confluence spaces response")?;
731
732 resp.results
733 .first()
734 .map(|s| s.id.clone())
735 .ok_or_else(|| anyhow::anyhow!("Space with key \"{space_key}\" not found"))
736 }
737
738 pub async fn create_page(
740 &self,
741 space_key: &str,
742 title: &str,
743 body_adf: &ValidatedAdfDocument,
744 parent_id: Option<&str>,
745 ) -> Result<String> {
746 let space_id = self.resolve_space_id(space_key).await?;
747
748 let adf_json =
749 serde_json::to_string(body_adf).context("Failed to serialize ADF document")?;
750
751 let request = ConfluenceCreateRequest {
752 space_id,
753 title: title.to_string(),
754 body: ConfluenceUpdateBody {
755 representation: "atlas_doc_format".to_string(),
756 value: adf_json,
757 },
758 parent_id: parent_id.map(String::from),
759 status: "current".to_string(),
760 };
761
762 let url = format!("{}/wiki/api/v2/pages", self.client.instance_url());
763
764 let response = self
765 .client
766 .post_json(&url, &request)
767 .await
768 .context("Failed to create Confluence page")?;
769
770 if !response.status().is_success() {
771 let status = response.status().as_u16();
772 let body = response.text().await.unwrap_or_default();
773 debug!(status, body = %body, "Confluence create_page non-success");
774 return Err(confluence_write_error(status, body, body_adf));
775 }
776
777 let resp: ConfluenceCreateResponse = response
778 .json()
779 .await
780 .context("Failed to parse Confluence create response")?;
781
782 Ok(resp.id)
783 }
784
785 pub async fn move_page(
792 &self,
793 page_id: &str,
794 target_id: &str,
795 position: MovePosition,
796 ) -> Result<MovedPage> {
797 let url = format!(
798 "{}/wiki/rest/api/content/{}/move/{}/{}",
799 self.client.instance_url(),
800 page_id,
801 position.as_str(),
802 target_id
803 );
804
805 let response = self
806 .client
807 .put_json(&url, &serde_json::json!({}))
808 .await
809 .context("Failed to send Confluence move request")?;
810
811 if !response.status().is_success() {
812 let status = response.status().as_u16();
813 let body = response.text().await.unwrap_or_default();
814 if status == 403 {
815 anyhow::bail!(
816 "Move failed: insufficient permissions to move page {page_id} \
817 relative to target {target_id}. Confluence response: {body}"
818 );
819 }
820 if status == 404 {
821 anyhow::bail!(
822 "Move failed: page {page_id} or target {target_id} not found, \
823 or insufficient permissions. Confluence response: {body}"
824 );
825 }
826 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
827 }
828
829 let page = self.fetch_page_with_ancestors(page_id).await?;
830 Ok(MovedPage {
831 id: page.id,
832 title: page.title,
833 parent_id: page.parent_id,
834 ancestors: page.ancestors.into_iter().map(|a| a.id).collect(),
835 })
836 }
837
838 async fn fetch_page_with_ancestors(&self, id: &str) -> Result<ConfluencePageResponse> {
840 let url = format!(
841 "{}/wiki/api/v2/pages/{}?include-ancestors=true",
842 self.client.instance_url(),
843 id
844 );
845
846 let response = self
847 .client
848 .get_json(&url)
849 .await
850 .context("Failed to fetch Confluence page with ancestors")?;
851
852 if !response.status().is_success() {
853 let status = response.status().as_u16();
854 let body = response.text().await.unwrap_or_default();
855 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
856 }
857
858 response
859 .json()
860 .await
861 .context("Failed to parse Confluence page response")
862 }
863
864 pub async fn delete_page(&self, id: &str, purge: bool) -> Result<()> {
866 let mut url = format!("{}/wiki/api/v2/pages/{}", self.client.instance_url(), id);
867 if purge {
868 url.push_str("?purge=true");
869 }
870
871 let response = self.client.delete(&url).await?;
872
873 if !response.status().is_success() {
874 let status = response.status().as_u16();
875 let body = response.text().await.unwrap_or_default();
876 if status == 404 {
877 anyhow::bail!(
878 "Page {id} not found or insufficient permissions. \
879 Confluence returns 404 when the API user lacks space-level delete permission. \
880 Check Space Settings > Permissions."
881 );
882 }
883 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
884 }
885
886 Ok(())
887 }
888
889 pub async fn get_children(&self, page_id: &str) -> Result<Vec<ChildPage>> {
894 let mut all_children = Vec::new();
895 let mut url = format!(
896 "{}/wiki/rest/api/content/{}/child/page?limit=50",
897 self.client.instance_url(),
898 page_id
899 );
900
901 loop {
902 let response = self
903 .client
904 .get_json(&url)
905 .await
906 .context("Failed to fetch child pages")?;
907
908 if !response.status().is_success() {
909 let status = response.status().as_u16();
910 let body = response.text().await.unwrap_or_default();
911 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
912 }
913
914 let resp: ConfluenceChildrenResponse = response
915 .json()
916 .await
917 .context("Failed to parse children response")?;
918
919 let page_count = resp.results.len();
920 for child in resp.results {
921 all_children.push(ChildPage {
922 id: child.id,
923 title: child.title,
924 status: child.status.unwrap_or_default(),
925 parent_id: Some(page_id.to_string()),
926 space_key: None,
927 });
928 }
929
930 match resp.links.and_then(|l| l.next) {
931 Some(next_path) if page_count > 0 => {
932 url = format!("{}{}", self.client.instance_url(), next_path);
933 }
934 _ => break,
935 }
936 }
937
938 Ok(all_children)
939 }
940
941 pub async fn get_space_root_pages(&self, space_id: &str) -> Result<Vec<ChildPage>> {
945 let mut all_pages = Vec::new();
946 let mut url = format!(
947 "{}/wiki/api/v2/spaces/{}/pages?depth=root&limit=50",
948 self.client.instance_url(),
949 space_id
950 );
951
952 loop {
953 let response = self
954 .client
955 .get_json(&url)
956 .await
957 .context("Failed to fetch space root pages")?;
958
959 if !response.status().is_success() {
960 let status = response.status().as_u16();
961 let body = response.text().await.unwrap_or_default();
962 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
963 }
964
965 let resp: ConfluenceSpacePagesResponse = response
966 .json()
967 .await
968 .context("Failed to parse space pages response")?;
969
970 let page_count = resp.results.len();
971 for entry in resp.results {
972 all_pages.push(ChildPage {
973 id: entry.id,
974 title: entry.title,
975 status: entry.status.unwrap_or_default(),
976 parent_id: entry.parent_id,
977 space_key: None,
978 });
979 }
980
981 match resp.links.and_then(|l| l.next) {
982 Some(next_path) if page_count > 0 => {
983 url = format!("{}{}", self.client.instance_url(), next_path);
984 }
985 _ => break,
986 }
987 }
988
989 Ok(all_pages)
990 }
991
992 pub async fn get_page_comments(&self, page_id: &str) -> Result<Vec<ConfluenceComment>> {
994 let mut all_comments = Vec::new();
995 let mut url = format!(
996 "{}/wiki/api/v2/pages/{}/footer-comments?body-format=atlas_doc_format",
997 self.client.instance_url(),
998 page_id
999 );
1000
1001 loop {
1002 let response = self
1003 .client
1004 .get_json(&url)
1005 .await
1006 .context("Failed to fetch Confluence page comments")?;
1007
1008 if !response.status().is_success() {
1009 let status = response.status().as_u16();
1010 let body = response.text().await.unwrap_or_default();
1011 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
1012 }
1013
1014 let resp: ConfluenceCommentsResponse = response
1015 .json()
1016 .await
1017 .context("Failed to parse Confluence comments response")?;
1018
1019 let page_count = resp.results.len();
1020 for c in resp.results {
1021 let body_adf = c.body.and_then(|b| {
1022 b.atlas_doc_format
1023 .and_then(|a| serde_json::from_str(&a.value).ok())
1024 });
1025 let author = c
1026 .version
1027 .as_ref()
1028 .and_then(|v| v.author_id.clone())
1029 .unwrap_or_default();
1030 let created = c.version.and_then(|v| v.created_at).unwrap_or_default();
1031 all_comments.push(ConfluenceComment {
1032 id: c.id,
1033 author,
1034 body_adf,
1035 created,
1036 });
1037 }
1038
1039 match resp.links.and_then(|l| l.next) {
1040 Some(next_path) if page_count > 0 => {
1041 url = format!("{}{}", self.client.instance_url(), next_path);
1042 }
1043 _ => break,
1044 }
1045 }
1046
1047 Ok(all_comments)
1048 }
1049
1050 pub async fn add_page_comment(
1052 &self,
1053 page_id: &str,
1054 body_adf: &ValidatedAdfDocument,
1055 ) -> Result<()> {
1056 let adf_json =
1057 serde_json::to_string(body_adf).context("Failed to serialize ADF document")?;
1058
1059 let request = ConfluenceAddCommentRequest {
1060 page_id: page_id.to_string(),
1061 body: ConfluenceUpdateBody {
1062 representation: "atlas_doc_format".to_string(),
1063 value: adf_json,
1064 },
1065 };
1066
1067 let url = format!("{}/wiki/api/v2/footer-comments", self.client.instance_url());
1068
1069 let response = self
1070 .client
1071 .post_json(&url, &request)
1072 .await
1073 .context("Failed to add Confluence page comment")?;
1074
1075 if !response.status().is_success() {
1076 let status = response.status().as_u16();
1077 let body = response.text().await.unwrap_or_default();
1078 debug!(status, body = %body, "Confluence add_page_comment non-success");
1079 return Err(confluence_write_error(status, body, body_adf));
1080 }
1081
1082 Ok(())
1083 }
1084
1085 async fn resolve_space_key(&self, space_id: &str) -> Result<String> {
1087 let url = format!(
1088 "{}/wiki/api/v2/spaces/{}",
1089 self.client.instance_url(),
1090 space_id
1091 );
1092
1093 let response = self
1094 .client
1095 .get_json(&url)
1096 .await
1097 .context("Failed to fetch Confluence space")?;
1098
1099 if !response.status().is_success() {
1100 return Ok(space_id.to_string());
1102 }
1103
1104 let space: ConfluenceSpaceResponse = response
1105 .json()
1106 .await
1107 .context("Failed to parse Confluence space response")?;
1108
1109 Ok(space.key)
1110 }
1111
1112 pub async fn get_labels(&self, page_id: &str) -> Result<Vec<ConfluenceLabel>> {
1114 let mut all_labels = Vec::new();
1115 let mut url = format!(
1116 "{}/wiki/api/v2/pages/{}/labels",
1117 self.client.instance_url(),
1118 page_id
1119 );
1120
1121 loop {
1122 let response = self
1123 .client
1124 .get_json(&url)
1125 .await
1126 .context("Failed to fetch page labels")?;
1127
1128 if !response.status().is_success() {
1129 let status = response.status().as_u16();
1130 let body = response.text().await.unwrap_or_default();
1131 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
1132 }
1133
1134 let resp: ConfluenceLabelsResponse = response
1135 .json()
1136 .await
1137 .context("Failed to parse labels response")?;
1138
1139 let page_count = resp.results.len();
1140 for entry in resp.results {
1141 all_labels.push(ConfluenceLabel {
1142 id: entry.id,
1143 name: entry.name,
1144 prefix: entry.prefix,
1145 });
1146 }
1147
1148 match resp.links.and_then(|l| l.next) {
1149 Some(next_path) if page_count > 0 => {
1150 url = format!("{}{}", self.client.instance_url(), next_path);
1151 }
1152 _ => break,
1153 }
1154 }
1155
1156 Ok(all_labels)
1157 }
1158
1159 pub async fn add_labels(&self, page_id: &str, labels: &[String]) -> Result<()> {
1161 let url = format!(
1162 "{}/wiki/rest/api/content/{}/label",
1163 self.client.instance_url(),
1164 page_id
1165 );
1166
1167 let body: Vec<ConfluenceAddLabelEntry> = labels
1168 .iter()
1169 .map(|name| ConfluenceAddLabelEntry {
1170 prefix: "global".to_string(),
1171 name: name.clone(),
1172 })
1173 .collect();
1174
1175 let response = self
1176 .client
1177 .post_json(&url, &body)
1178 .await
1179 .context("Failed to add labels")?;
1180
1181 if !response.status().is_success() {
1182 let status = response.status().as_u16();
1183 let body = response.text().await.unwrap_or_default();
1184 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
1185 }
1186
1187 Ok(())
1188 }
1189
1190 pub async fn remove_label(&self, page_id: &str, label_name: &str) -> Result<()> {
1192 let url = format!(
1193 "{}/wiki/rest/api/content/{}/label/{}",
1194 self.client.instance_url(),
1195 page_id,
1196 label_name
1197 );
1198
1199 let response = self.client.delete(&url).await?;
1200
1201 if !response.status().is_success() {
1202 let status = response.status().as_u16();
1203 let body = response.text().await.unwrap_or_default();
1204 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
1205 }
1206
1207 Ok(())
1208 }
1209
1210 pub async fn get_page_metadata(&self, page_id: &str) -> Result<PageMetadata> {
1215 let url = format!(
1216 "{}/wiki/api/v2/pages/{}",
1217 self.client.instance_url(),
1218 page_id
1219 );
1220
1221 let response = self
1222 .client
1223 .get_json(&url)
1224 .await
1225 .context("Failed to fetch Confluence page metadata")?;
1226
1227 if !response.status().is_success() {
1228 let status = response.status().as_u16();
1229 let body = response.text().await.unwrap_or_default();
1230 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
1231 }
1232
1233 let page: ConfluencePageResponse = response
1234 .json()
1235 .await
1236 .context("Failed to parse Confluence page response")?;
1237
1238 Ok(PageMetadata {
1239 id: page.id,
1240 title: page.title,
1241 current_version: page.version.map(|v| v.number),
1242 })
1243 }
1244
1245 pub async fn list_page_versions(
1256 &self,
1257 page_id: &str,
1258 since: Option<&SinceFilter>,
1259 limit: u32,
1260 ) -> Result<(Vec<PageVersion>, bool)> {
1261 let page_size = if limit == 0 { 100 } else { limit.min(100) };
1263 let mut url = format!(
1264 "{}/wiki/api/v2/pages/{}/versions?limit={}",
1265 self.client.instance_url(),
1266 page_id,
1267 page_size
1268 );
1269
1270 let mut collected: Vec<PageVersion> = Vec::new();
1271
1272 loop {
1273 let response = self
1274 .client
1275 .get_json(&url)
1276 .await
1277 .context("Failed to fetch Confluence page versions")?;
1278
1279 if !response.status().is_success() {
1280 let status = response.status().as_u16();
1281 let body = response.text().await.unwrap_or_default();
1282 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
1283 }
1284
1285 let resp: ConfluenceVersionsResponse = response
1286 .json()
1287 .await
1288 .context("Failed to parse Confluence versions response")?;
1289
1290 let page_count = resp.results.len();
1291 let next_link = resp.links.and_then(|l| l.next);
1292
1293 for (idx, entry) in resp.results.into_iter().enumerate() {
1294 let version = PageVersion {
1295 number: entry.number,
1296 created_at: entry.created_at.unwrap_or_default(),
1297 author_id: entry.author_id.unwrap_or_default(),
1298 message: entry.message.unwrap_or_default(),
1299 minor_edit: entry.minor_edit.unwrap_or(false),
1300 };
1301
1302 if let Some(filter) = since {
1303 if !filter.matches(&version) {
1304 return Ok((collected, false));
1306 }
1307 }
1308
1309 collected.push(version);
1310 if limit > 0 && collected.len() as u32 >= limit {
1311 let more_on_page = idx + 1 < page_count;
1314 let has_next = next_link.is_some();
1315 return Ok((collected, more_on_page || has_next));
1316 }
1317 }
1318
1319 match next_link {
1320 Some(next_path) if page_count > 0 => {
1321 url = format!("{}{}", self.client.instance_url(), next_path);
1322 }
1323 _ => return Ok((collected, false)),
1324 }
1325 }
1326 }
1327
1328 pub async fn upload_attachment(
1336 &self,
1337 page_id: &str,
1338 file_path: &Path,
1339 filename: Option<&str>,
1340 comment: Option<&str>,
1341 minor_edit: bool,
1342 ) -> Result<ConfluenceAttachment> {
1343 let metadata = tokio::fs::metadata(file_path)
1344 .await
1345 .with_context(|| format!("Failed to read file metadata for {}", file_path.display()))?;
1346 let size = metadata.len();
1347 let file = tokio::fs::File::open(file_path)
1348 .await
1349 .with_context(|| format!("Failed to open {}", file_path.display()))?;
1350
1351 let resolved_name = filename
1352 .map(str::to_string)
1353 .or_else(|| {
1354 file_path
1355 .file_name()
1356 .map(|s| s.to_string_lossy().into_owned())
1357 })
1358 .ok_or_else(|| anyhow::anyhow!("File path has no filename component"))?;
1359
1360 let mime = mime_guess::from_path(file_path).first_or_octet_stream();
1361
1362 let stream = ReaderStream::new(file);
1363 let body = reqwest::Body::wrap_stream(stream);
1364
1365 let part = reqwest::multipart::Part::stream_with_length(body, size)
1366 .file_name(resolved_name.clone())
1367 .mime_str(mime.essence_str())
1368 .with_context(|| format!("Invalid MIME type for {}", file_path.display()))?;
1369
1370 let mut form = reqwest::multipart::Form::new().part("file", part);
1371 if let Some(c) = comment {
1372 form = form.text("comment", c.to_string());
1373 }
1374 form = form.text("minorEdit", if minor_edit { "true" } else { "false" });
1375
1376 let url = format!(
1377 "{}/wiki/api/v2/pages/{}/attachments",
1378 self.client.instance_url(),
1379 page_id
1380 );
1381
1382 let response = self
1383 .client
1384 .post_multipart(&url, form, &[("X-Atlassian-Token", "no-check")])
1385 .await?;
1386
1387 if !response.status().is_success() {
1388 let status = response.status().as_u16();
1389 let body = response.text().await.unwrap_or_default();
1390 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
1391 }
1392
1393 let resp: ConfluenceAttachmentsResponse = response
1394 .json()
1395 .await
1396 .context("Failed to parse upload attachment response")?;
1397
1398 let entry = resp
1399 .results
1400 .into_iter()
1401 .next()
1402 .ok_or_else(|| anyhow::anyhow!("Upload response contained no attachment"))?;
1403 Ok(entry.into())
1404 }
1405
1406 pub async fn list_attachments(
1412 &self,
1413 page_id: &str,
1414 cursor: Option<&str>,
1415 limit: u32,
1416 ) -> Result<ConfluenceAttachmentPage> {
1417 let mut url = format!(
1418 "{}/wiki/api/v2/pages/{}/attachments?limit={}",
1419 self.client.instance_url(),
1420 page_id,
1421 limit,
1422 );
1423 if let Some(c) = cursor {
1424 url.push_str("&cursor=");
1425 url.push_str(&urlencoding(c));
1426 }
1427
1428 let response = self
1429 .client
1430 .get_json(&url)
1431 .await
1432 .context("Failed to fetch page attachments")?;
1433
1434 if !response.status().is_success() {
1435 let status = response.status().as_u16();
1436 let body = response.text().await.unwrap_or_default();
1437 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
1438 }
1439
1440 let resp: ConfluenceAttachmentsResponse = response
1441 .json()
1442 .await
1443 .context("Failed to parse attachments response")?;
1444
1445 let next_cursor = resp
1446 .links
1447 .and_then(|l| l.next)
1448 .and_then(|next_path| extract_cursor_from_next(&next_path));
1449
1450 let results = resp.results.into_iter().map(Into::into).collect();
1451
1452 Ok(ConfluenceAttachmentPage {
1453 results,
1454 next_cursor,
1455 })
1456 }
1457
1458 pub async fn delete_attachment(&self, attachment_id: &str, purge: bool) -> Result<()> {
1463 let mut url = format!(
1464 "{}/wiki/api/v2/attachments/{}",
1465 self.client.instance_url(),
1466 attachment_id
1467 );
1468 if purge {
1469 url.push_str("?purge=true");
1470 }
1471
1472 let response = self.client.delete(&url).await?;
1473
1474 if !response.status().is_success() {
1475 let status = response.status().as_u16();
1476 let body = response.text().await.unwrap_or_default();
1477 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
1478 }
1479
1480 Ok(())
1481 }
1482
1483 pub async fn get_page_at_version(&self, id: &str, version: u32) -> Result<ContentItem> {
1490 let url = format!(
1491 "{}/wiki/api/v2/pages/{}?body-format=atlas_doc_format&version={}",
1492 self.client.instance_url(),
1493 id,
1494 version
1495 );
1496
1497 let response = self
1498 .client
1499 .get_json(&url)
1500 .await
1501 .context("Failed to fetch Confluence page version")?;
1502
1503 if !response.status().is_success() {
1504 let status = response.status().as_u16();
1505 let body = response.text().await.unwrap_or_default();
1506 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
1507 }
1508
1509 let page: ConfluencePageResponse = response
1510 .json()
1511 .await
1512 .context("Failed to parse Confluence page response")?;
1513
1514 debug!(
1515 page_id = page.id,
1516 version,
1517 title = page.title,
1518 "Fetched Confluence page at specific version"
1519 );
1520
1521 let body_adf = if let Some(body) = &page.body {
1522 if let Some(atlas_doc) = &body.atlas_doc_format {
1523 Some(
1524 serde_json::from_str(&atlas_doc.value)
1525 .context("Failed to parse ADF from Confluence body")?,
1526 )
1527 } else {
1528 None
1529 }
1530 } else {
1531 None
1532 };
1533
1534 let space_key = self.resolve_space_key(&page.space_id).await?;
1535
1536 Ok(ContentItem {
1537 id: page.id,
1538 title: page.title,
1539 body_adf,
1540 metadata: ContentMetadata::Confluence {
1541 space_key,
1542 status: Some(page.status),
1543 version: page.version.map(|v| v.number),
1544 parent_id: page.parent_id,
1545 },
1546 })
1547 }
1548}
1549
1550fn urlencoding(s: &str) -> String {
1556 let mut out = String::with_capacity(s.len());
1557 for c in s.chars() {
1558 match c {
1559 '&' => out.push_str("%26"),
1560 '=' => out.push_str("%3D"),
1561 '+' => out.push_str("%2B"),
1562 '%' => out.push_str("%25"),
1563 '#' => out.push_str("%23"),
1564 ' ' => out.push_str("%20"),
1565 _ => out.push(c),
1566 }
1567 }
1568 out
1569}
1570
1571fn extract_cursor_from_next(next: &str) -> Option<String> {
1573 let query_start = next.find('?')?;
1574 let query = &next[query_start + 1..];
1575 for pair in query.split('&') {
1576 let mut it = pair.splitn(2, '=');
1577 let key = it.next()?;
1578 let value = it.next().unwrap_or("");
1579 if key == "cursor" {
1580 return Some(percent_decode(value));
1581 }
1582 }
1583 None
1584}
1585
1586fn percent_decode(s: &str) -> String {
1588 let bytes = s.as_bytes();
1589 let mut out = Vec::with_capacity(bytes.len());
1590 let mut i = 0;
1591 while i < bytes.len() {
1592 if bytes[i] == b'%' && i + 2 < bytes.len() {
1593 let hi = (bytes[i + 1] as char).to_digit(16);
1594 let lo = (bytes[i + 2] as char).to_digit(16);
1595 if let (Some(hi), Some(lo)) = (hi, lo) {
1596 out.push(((hi << 4) | lo) as u8);
1597 i += 3;
1598 continue;
1599 }
1600 }
1601 out.push(bytes[i]);
1602 i += 1;
1603 }
1604 String::from_utf8_lossy(&out).into_owned()
1605}
1606
1607pub fn resolve_version(raw: &str, versions: &[PageVersion], relative_to: u32) -> Result<u32> {
1625 let trimmed = raw.trim();
1626 if trimmed.is_empty() {
1627 anyhow::bail!("version reference must not be empty");
1628 }
1629 if versions.is_empty() {
1630 anyhow::bail!("page has no versions");
1631 }
1632
1633 if trimmed.eq_ignore_ascii_case("latest") {
1634 return Ok(versions[0].number);
1635 }
1636 if trimmed.eq_ignore_ascii_case("previous") {
1637 return offset_from(relative_to, 1, versions);
1638 }
1639 if let Some(rest) = trimmed
1640 .strip_prefix("v-")
1641 .or_else(|| trimmed.strip_prefix("V-"))
1642 {
1643 let offset: u32 = rest.parse().with_context(|| {
1644 format!("Invalid relative version offset \"{trimmed}\"; expected v-N with N > 0")
1645 })?;
1646 if offset == 0 {
1647 anyhow::bail!("Relative version offset must be > 0; got \"{trimmed}\"");
1648 }
1649 return offset_from(relative_to, offset, versions);
1650 }
1651 if trimmed.chars().all(|c| c.is_ascii_digit()) {
1652 let n: u32 = trimmed
1653 .parse()
1654 .with_context(|| format!("Invalid version number \"{trimmed}\""))?;
1655 if !versions.iter().any(|v| v.number == n) {
1656 anyhow::bail!("Version {n} not found in page history");
1657 }
1658 return Ok(n);
1659 }
1660 if trimmed.contains('-') || trimmed.contains('T') {
1661 for v in versions {
1664 if !v.created_at.is_empty() && v.created_at.as_str() <= trimmed {
1665 return Ok(v.number);
1666 }
1667 }
1668 anyhow::bail!("No version found at or before \"{trimmed}\"");
1669 }
1670
1671 anyhow::bail!(
1672 "Could not parse \"{trimmed}\" as a version reference; expected \
1673 \"latest\", \"previous\", \"v-N\", a numeric version (e.g. \"5\"), \
1674 or an ISO 8601 date (e.g. \"2026-01-01T00:00:00Z\")"
1675 )
1676}
1677
1678fn offset_from(anchor: u32, offset: u32, versions: &[PageVersion]) -> Result<u32> {
1679 if anchor <= offset {
1680 anyhow::bail!(
1681 "Cannot resolve v-{offset} relative to version {anchor}: out of range \
1682 (would be {} or lower)",
1683 i64::from(anchor) - i64::from(offset)
1684 );
1685 }
1686 let target = anchor - offset;
1687 if !versions.iter().any(|v| v.number == target) {
1688 anyhow::bail!(
1689 "Version {target} not found in page history \
1690 (resolved from anchor {anchor} - {offset})"
1691 );
1692 }
1693 Ok(target)
1694}
1695
1696#[cfg(test)]
1697#[allow(clippy::unwrap_used, clippy::expect_used)]
1698mod tests {
1699 use super::*;
1700
1701 #[test]
1702 fn confluence_api_backend_name() {
1703 let client =
1704 AtlassianClient::new("https://org.atlassian.net", "user@test.com", "token").unwrap();
1705 let api = ConfluenceApi::new(client);
1706 assert_eq!(api.backend_name(), "confluence");
1707 }
1708
1709 #[test]
1710 fn confluence_page_response_deserialization() {
1711 let json = r#"{
1712 "id": "12345",
1713 "title": "Test Page",
1714 "status": "current",
1715 "spaceId": "98765",
1716 "version": {"number": 3},
1717 "body": {
1718 "atlas_doc_format": {
1719 "value": "{\"version\":1,\"type\":\"doc\",\"content\":[]}"
1720 }
1721 },
1722 "parentId": "11111"
1723 }"#;
1724 let page: ConfluencePageResponse = serde_json::from_str(json).unwrap();
1725 assert_eq!(page.id, "12345");
1726 assert_eq!(page.title, "Test Page");
1727 assert_eq!(page.status, "current");
1728 assert_eq!(page.space_id, "98765");
1729 assert_eq!(page.version.unwrap().number, 3);
1730 assert_eq!(page.parent_id.as_deref(), Some("11111"));
1731
1732 let body = page.body.unwrap();
1733 let atlas_doc = body.atlas_doc_format.unwrap();
1734 let adf: serde_json::Value = serde_json::from_str(&atlas_doc.value).unwrap();
1735 assert_eq!(adf["version"], 1);
1736 assert_eq!(adf["type"], "doc");
1737 }
1738
1739 #[test]
1740 fn confluence_page_response_minimal() {
1741 let json = r#"{
1742 "id": "99",
1743 "title": "Minimal",
1744 "status": "draft",
1745 "spaceId": "1"
1746 }"#;
1747 let page: ConfluencePageResponse = serde_json::from_str(json).unwrap();
1748 assert_eq!(page.id, "99");
1749 assert!(page.version.is_none());
1750 assert!(page.body.is_none());
1751 assert!(page.parent_id.is_none());
1752 }
1753
1754 #[test]
1755 fn confluence_update_request_serialization() {
1756 let req = ConfluenceUpdateRequest {
1757 id: "12345".to_string(),
1758 status: "current".to_string(),
1759 title: "Updated Title".to_string(),
1760 body: ConfluenceUpdateBody {
1761 representation: "atlas_doc_format".to_string(),
1762 value: r#"{"version":1,"type":"doc","content":[]}"#.to_string(),
1763 },
1764 version: ConfluenceUpdateVersion {
1765 number: 4,
1766 message: None,
1767 },
1768 };
1769
1770 let json = serde_json::to_value(&req).unwrap();
1771 assert_eq!(json["id"], "12345");
1772 assert_eq!(json["status"], "current");
1773 assert_eq!(json["title"], "Updated Title");
1774 assert_eq!(json["body"]["representation"], "atlas_doc_format");
1775 assert_eq!(json["version"]["number"], 4);
1776 }
1777
1778 #[test]
1779 fn confluence_update_version_with_message() {
1780 let req = ConfluenceUpdateRequest {
1781 id: "1".to_string(),
1782 status: "current".to_string(),
1783 title: "T".to_string(),
1784 body: ConfluenceUpdateBody {
1785 representation: "atlas_doc_format".to_string(),
1786 value: "{}".to_string(),
1787 },
1788 version: ConfluenceUpdateVersion {
1789 number: 2,
1790 message: Some("Updated via API".to_string()),
1791 },
1792 };
1793 let json = serde_json::to_value(&req).unwrap();
1794 assert_eq!(json["version"]["message"], "Updated via API");
1795 }
1796
1797 #[test]
1798 fn confluence_space_response_deserialization() {
1799 let json = r#"{"key": "ENG"}"#;
1800 let space: ConfluenceSpaceResponse = serde_json::from_str(json).unwrap();
1801 assert_eq!(space.key, "ENG");
1802 }
1803
1804 fn adf_with_panel_expand() -> AdfDocument {
1809 use crate::atlassian::adf::AdfNode;
1810 AdfDocument {
1811 version: 1,
1812 doc_type: "doc".to_string(),
1813 content: vec![AdfNode {
1814 node_type: "panel".to_string(),
1815 attrs: Some(serde_json::json!({"panelType": "info"})),
1816 content: Some(vec![AdfNode {
1817 node_type: "expand".to_string(),
1818 attrs: Some(serde_json::json!({"title": "details"})),
1819 content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("x")])]),
1820 text: None,
1821 marks: None,
1822 local_id: None,
1823 parameters: None,
1824 }]),
1825 text: None,
1826 marks: None,
1827 local_id: None,
1828 parameters: None,
1829 }],
1830 }
1831 }
1832
1833 async fn setup_confluence_mock() -> (wiremock::MockServer, ConfluenceApi) {
1835 let server = wiremock::MockServer::start().await;
1836
1837 let page_json = serde_json::json!({
1838 "id": "12345",
1839 "title": "Test Page",
1840 "status": "current",
1841 "spaceId": "98765",
1842 "version": {"number": 3},
1843 "body": {
1844 "atlas_doc_format": {
1845 "value": "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"Hello\"}]}]}"
1846 }
1847 },
1848 "parentId": "11111"
1849 });
1850
1851 wiremock::Mock::given(wiremock::matchers::method("GET"))
1852 .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
1853 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&page_json))
1854 .mount(&server)
1855 .await;
1856
1857 wiremock::Mock::given(wiremock::matchers::method("GET"))
1858 .and(wiremock::matchers::path("/wiki/api/v2/spaces/98765"))
1859 .respond_with(
1860 wiremock::ResponseTemplate::new(200)
1861 .set_body_json(serde_json::json!({"key": "ENG"})),
1862 )
1863 .mount(&server)
1864 .await;
1865
1866 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1867 let api = ConfluenceApi::new(client);
1868
1869 (server, api)
1870 }
1871
1872 #[tokio::test]
1873 async fn get_content_success() {
1874 use crate::atlassian::api::{AtlassianApi, ContentMetadata};
1875
1876 let (_server, api) = setup_confluence_mock().await;
1877 let item = api.get_content("12345").await.unwrap();
1878
1879 assert_eq!(item.id, "12345");
1880 assert_eq!(item.title, "Test Page");
1881 assert!(item.body_adf.is_some());
1882 match &item.metadata {
1883 ContentMetadata::Confluence {
1884 space_key,
1885 status,
1886 version,
1887 parent_id,
1888 } => {
1889 assert_eq!(space_key, "ENG");
1890 assert_eq!(status.as_deref(), Some("current"));
1891 assert_eq!(*version, Some(3));
1892 assert_eq!(parent_id.as_deref(), Some("11111"));
1893 }
1894 ContentMetadata::Jira { .. } => panic!("Expected Confluence metadata"),
1895 }
1896 }
1897
1898 #[tokio::test]
1899 async fn get_content_api_error() {
1900 use crate::atlassian::api::AtlassianApi;
1901
1902 let server = wiremock::MockServer::start().await;
1903
1904 wiremock::Mock::given(wiremock::matchers::method("GET"))
1905 .and(wiremock::matchers::path("/wiki/api/v2/pages/99999"))
1906 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
1907 .mount(&server)
1908 .await;
1909
1910 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1911 let api = ConfluenceApi::new(client);
1912 let err = api.get_content("99999").await.unwrap_err();
1913 assert!(err.to_string().contains("404"));
1914 }
1915
1916 #[tokio::test]
1917 async fn get_content_no_body() {
1918 use crate::atlassian::api::AtlassianApi;
1919
1920 let server = wiremock::MockServer::start().await;
1921
1922 wiremock::Mock::given(wiremock::matchers::method("GET"))
1923 .and(wiremock::matchers::path("/wiki/api/v2/pages/55555"))
1924 .respond_with(
1925 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1926 "id": "55555",
1927 "title": "No Body",
1928 "status": "draft",
1929 "spaceId": "11111"
1930 })),
1931 )
1932 .mount(&server)
1933 .await;
1934
1935 wiremock::Mock::given(wiremock::matchers::method("GET"))
1936 .and(wiremock::matchers::path("/wiki/api/v2/spaces/11111"))
1937 .respond_with(
1938 wiremock::ResponseTemplate::new(200)
1939 .set_body_json(serde_json::json!({"key": "DEV"})),
1940 )
1941 .mount(&server)
1942 .await;
1943
1944 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1945 let api = ConfluenceApi::new(client);
1946 let item = api.get_content("55555").await.unwrap();
1947 assert!(item.body_adf.is_none());
1948 }
1949
1950 #[tokio::test]
1951 async fn update_content_success() {
1952 use crate::atlassian::api::AtlassianApi;
1953
1954 let (server, api) = setup_confluence_mock().await;
1955
1956 wiremock::Mock::given(wiremock::matchers::method("PUT"))
1957 .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
1958 .respond_with(wiremock::ResponseTemplate::new(200))
1959 .mount(&server)
1960 .await;
1961
1962 let adf = ValidatedAdfDocument::empty();
1963 let result = api.update_content("12345", &adf, Some("New Title")).await;
1964 assert!(result.is_ok());
1965 }
1966
1967 #[tokio::test]
1968 async fn update_content_api_error() {
1969 use crate::atlassian::api::AtlassianApi;
1970
1971 let (server, api) = setup_confluence_mock().await;
1972
1973 wiremock::Mock::given(wiremock::matchers::method("PUT"))
1974 .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
1975 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
1976 .mount(&server)
1977 .await;
1978
1979 let adf = ValidatedAdfDocument::empty();
1980 let err = api.update_content("12345", &adf, None).await.unwrap_err();
1981 assert!(err.to_string().contains("403"));
1982 }
1983
1984 #[tokio::test]
1985 async fn update_content_500_with_panel_expand_diagnoses() {
1986 use crate::atlassian::api::AtlassianApi;
1987
1988 let (server, api) = setup_confluence_mock().await;
1989
1990 wiremock::Mock::given(wiremock::matchers::method("PUT"))
1991 .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
1992 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string(
1993 "{\"errors\":[{\"status\":500,\"code\":\"INTERNAL_SERVER_ERROR\"}]}",
1994 ))
1995 .mount(&server)
1996 .await;
1997
1998 let adf = ValidatedAdfDocument::trust(adf_with_panel_expand());
1999 let err = api.update_content("12345", &adf, None).await.unwrap_err();
2000 let msg = err.to_string();
2001 assert!(
2002 msg.contains("Confluence API returned HTTP 500 (Internal Server Error)"),
2003 "missing 500 header in: {msg}"
2004 );
2005 assert!(
2006 msg.contains("Diagnosis:"),
2007 "missing Diagnosis line in: {msg}"
2008 );
2009 assert!(msg.contains("`expand`"), "missing child name in: {msg}");
2010 assert!(msg.contains("`panel`"), "missing parent name in: {msg}");
2011 assert!(msg.contains("Hint:"), "missing Hint line in: {msg}");
2012 assert!(
2013 !msg.contains("INTERNAL_SERVER_ERROR"),
2014 "raw response body should not be in user-facing message: {msg}"
2015 );
2016 }
2017
2018 #[tokio::test]
2019 async fn update_content_500_without_violation_falls_back() {
2020 use crate::atlassian::api::AtlassianApi;
2021
2022 let (server, api) = setup_confluence_mock().await;
2023
2024 wiremock::Mock::given(wiremock::matchers::method("PUT"))
2025 .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
2026 .respond_with(
2027 wiremock::ResponseTemplate::new(500).set_body_string("Internal Server Error"),
2028 )
2029 .mount(&server)
2030 .await;
2031
2032 let adf = ValidatedAdfDocument::empty();
2034 let err = api.update_content("12345", &adf, None).await.unwrap_err();
2035 let msg = err.to_string();
2036 assert!(msg.contains("HTTP 500"), "missing status in: {msg}");
2037 assert!(
2038 msg.contains("Internal Server Error"),
2039 "fallback should include raw body: {msg}"
2040 );
2041 assert!(
2042 !msg.contains("Diagnosis:"),
2043 "fallback should not include diagnosis: {msg}"
2044 );
2045 }
2046
2047 #[tokio::test]
2048 async fn verify_auth_success() {
2049 use crate::atlassian::api::AtlassianApi;
2050
2051 let server = wiremock::MockServer::start().await;
2052
2053 wiremock::Mock::given(wiremock::matchers::method("GET"))
2054 .and(wiremock::matchers::path("/rest/api/3/myself"))
2055 .respond_with(
2056 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2057 "displayName": "Alice",
2058 "accountId": "abc123"
2059 })),
2060 )
2061 .mount(&server)
2062 .await;
2063
2064 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2065 let api = ConfluenceApi::new(client);
2066 let name = api.verify_auth().await.unwrap();
2067 assert_eq!(name, "Alice");
2068 }
2069
2070 #[tokio::test]
2071 async fn resolve_space_id_success() {
2072 let server = wiremock::MockServer::start().await;
2073
2074 wiremock::Mock::given(wiremock::matchers::method("GET"))
2075 .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
2076 .respond_with(
2077 wiremock::ResponseTemplate::new(200)
2078 .set_body_json(serde_json::json!({"results": [{"id": "98765"}]})),
2079 )
2080 .mount(&server)
2081 .await;
2082
2083 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2084 let api = ConfluenceApi::new(client);
2085 let id = api.resolve_space_id("ENG").await.unwrap();
2086 assert_eq!(id, "98765");
2087 }
2088
2089 #[tokio::test]
2090 async fn resolve_space_id_not_found() {
2091 let server = wiremock::MockServer::start().await;
2092
2093 wiremock::Mock::given(wiremock::matchers::method("GET"))
2094 .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
2095 .respond_with(
2096 wiremock::ResponseTemplate::new(200)
2097 .set_body_json(serde_json::json!({"results": []})),
2098 )
2099 .mount(&server)
2100 .await;
2101
2102 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2103 let api = ConfluenceApi::new(client);
2104 let err = api.resolve_space_id("NOPE").await.unwrap_err();
2105 assert!(err.to_string().contains("not found"));
2106 }
2107
2108 #[tokio::test]
2109 async fn resolve_space_id_api_error() {
2110 let server = wiremock::MockServer::start().await;
2111
2112 wiremock::Mock::given(wiremock::matchers::method("GET"))
2113 .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
2114 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
2115 .mount(&server)
2116 .await;
2117
2118 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2119 let api = ConfluenceApi::new(client);
2120 let err = api.resolve_space_id("ENG").await.unwrap_err();
2121 assert!(err.to_string().contains("403"));
2122 }
2123
2124 #[tokio::test]
2125 async fn create_page_success() {
2126 let server = wiremock::MockServer::start().await;
2127
2128 wiremock::Mock::given(wiremock::matchers::method("GET"))
2130 .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
2131 .respond_with(
2132 wiremock::ResponseTemplate::new(200)
2133 .set_body_json(serde_json::json!({"results": [{"id": "98765"}]})),
2134 )
2135 .mount(&server)
2136 .await;
2137
2138 wiremock::Mock::given(wiremock::matchers::method("POST"))
2140 .and(wiremock::matchers::path("/wiki/api/v2/pages"))
2141 .respond_with(
2142 wiremock::ResponseTemplate::new(200)
2143 .set_body_json(serde_json::json!({"id": "54321"})),
2144 )
2145 .mount(&server)
2146 .await;
2147
2148 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2149 let api = ConfluenceApi::new(client);
2150 let adf = ValidatedAdfDocument::empty();
2151 let id = api
2152 .create_page("ENG", "New Page", &adf, None)
2153 .await
2154 .unwrap();
2155 assert_eq!(id, "54321");
2156 }
2157
2158 #[tokio::test]
2159 async fn create_page_with_parent() {
2160 let server = wiremock::MockServer::start().await;
2161
2162 wiremock::Mock::given(wiremock::matchers::method("GET"))
2163 .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
2164 .respond_with(
2165 wiremock::ResponseTemplate::new(200)
2166 .set_body_json(serde_json::json!({"results": [{"id": "98765"}]})),
2167 )
2168 .mount(&server)
2169 .await;
2170
2171 wiremock::Mock::given(wiremock::matchers::method("POST"))
2172 .and(wiremock::matchers::path("/wiki/api/v2/pages"))
2173 .respond_with(
2174 wiremock::ResponseTemplate::new(200)
2175 .set_body_json(serde_json::json!({"id": "54322"})),
2176 )
2177 .mount(&server)
2178 .await;
2179
2180 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2181 let api = ConfluenceApi::new(client);
2182 let adf = ValidatedAdfDocument::empty();
2183 let id = api
2184 .create_page("ENG", "Child Page", &adf, Some("11111"))
2185 .await
2186 .unwrap();
2187 assert_eq!(id, "54322");
2188 }
2189
2190 #[tokio::test]
2191 async fn create_page_api_error() {
2192 let server = wiremock::MockServer::start().await;
2193
2194 wiremock::Mock::given(wiremock::matchers::method("GET"))
2195 .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
2196 .respond_with(
2197 wiremock::ResponseTemplate::new(200)
2198 .set_body_json(serde_json::json!({"results": [{"id": "98765"}]})),
2199 )
2200 .mount(&server)
2201 .await;
2202
2203 wiremock::Mock::given(wiremock::matchers::method("POST"))
2204 .and(wiremock::matchers::path("/wiki/api/v2/pages"))
2205 .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Bad Request"))
2206 .mount(&server)
2207 .await;
2208
2209 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2210 let api = ConfluenceApi::new(client);
2211 let adf = ValidatedAdfDocument::empty();
2212 let err = api
2213 .create_page("ENG", "Fail", &adf, None)
2214 .await
2215 .unwrap_err();
2216 assert!(err.to_string().contains("400"));
2217 }
2218
2219 #[tokio::test]
2220 async fn create_page_500_with_panel_expand_diagnoses() {
2221 let server = wiremock::MockServer::start().await;
2222
2223 wiremock::Mock::given(wiremock::matchers::method("GET"))
2224 .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
2225 .respond_with(
2226 wiremock::ResponseTemplate::new(200)
2227 .set_body_json(serde_json::json!({"results": [{"id": "98765"}]})),
2228 )
2229 .mount(&server)
2230 .await;
2231
2232 wiremock::Mock::given(wiremock::matchers::method("POST"))
2233 .and(wiremock::matchers::path("/wiki/api/v2/pages"))
2234 .respond_with(
2235 wiremock::ResponseTemplate::new(500).set_body_string("Internal Server Error"),
2236 )
2237 .mount(&server)
2238 .await;
2239
2240 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2241 let api = ConfluenceApi::new(client);
2242 let adf = ValidatedAdfDocument::trust(adf_with_panel_expand());
2243 let err = api.create_page("ENG", "Bad", &adf, None).await.unwrap_err();
2244 let msg = err.to_string();
2245 assert!(msg.contains("HTTP 500"), "missing status in: {msg}");
2246 assert!(
2247 msg.contains("Diagnosis:"),
2248 "missing Diagnosis line in: {msg}"
2249 );
2250 assert!(msg.contains("`expand`"), "missing child name in: {msg}");
2251 assert!(msg.contains("`panel`"), "missing parent name in: {msg}");
2252 assert!(msg.contains("Hint:"), "missing Hint line in: {msg}");
2253 }
2254
2255 #[tokio::test]
2256 async fn delete_page_success() {
2257 let server = wiremock::MockServer::start().await;
2258
2259 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
2260 .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
2261 .respond_with(wiremock::ResponseTemplate::new(204))
2262 .expect(1)
2263 .mount(&server)
2264 .await;
2265
2266 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2267 let api = ConfluenceApi::new(client);
2268 let result = api.delete_page("12345", false).await;
2269 assert!(result.is_ok());
2270 }
2271
2272 #[tokio::test]
2273 async fn delete_page_with_purge() {
2274 let server = wiremock::MockServer::start().await;
2275
2276 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
2277 .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
2278 .and(wiremock::matchers::query_param("purge", "true"))
2279 .respond_with(wiremock::ResponseTemplate::new(204))
2280 .expect(1)
2281 .mount(&server)
2282 .await;
2283
2284 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2285 let api = ConfluenceApi::new(client);
2286 let result = api.delete_page("12345", true).await;
2287 assert!(result.is_ok());
2288 }
2289
2290 #[tokio::test]
2291 async fn delete_page_not_found_hints_permissions() {
2292 let server = wiremock::MockServer::start().await;
2293
2294 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
2295 .and(wiremock::matchers::path("/wiki/api/v2/pages/99999"))
2296 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
2297 .expect(1)
2298 .mount(&server)
2299 .await;
2300
2301 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2302 let api = ConfluenceApi::new(client);
2303 let err = api.delete_page("99999", false).await.unwrap_err();
2304 let msg = err.to_string();
2305 assert!(msg.contains("not found or insufficient permissions"));
2306 assert!(msg.contains("Space Settings"));
2307 }
2308
2309 #[tokio::test]
2310 async fn delete_page_forbidden() {
2311 let server = wiremock::MockServer::start().await;
2312
2313 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
2314 .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
2315 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
2316 .expect(1)
2317 .mount(&server)
2318 .await;
2319
2320 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2321 let api = ConfluenceApi::new(client);
2322 let err = api.delete_page("12345", false).await.unwrap_err();
2323 assert!(err.to_string().contains("403"));
2324 }
2325
2326 #[test]
2329 fn move_position_as_str() {
2330 assert_eq!(MovePosition::Append.as_str(), "append");
2331 assert_eq!(MovePosition::Before.as_str(), "before");
2332 assert_eq!(MovePosition::After.as_str(), "after");
2333 }
2334
2335 async fn mount_ancestor_fetch(server: &wiremock::MockServer, id: &str, parent_id: &str) {
2337 wiremock::Mock::given(wiremock::matchers::method("GET"))
2338 .and(wiremock::matchers::path(format!("/wiki/api/v2/pages/{id}")))
2339 .and(wiremock::matchers::query_param("include-ancestors", "true"))
2340 .respond_with(
2341 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2342 "id": id,
2343 "title": "Moved Page",
2344 "status": "current",
2345 "spaceId": "98765",
2346 "parentId": parent_id,
2347 "ancestors": [
2348 {"id": "10"},
2349 {"id": parent_id}
2350 ]
2351 })),
2352 )
2353 .mount(server)
2354 .await;
2355 }
2356
2357 #[tokio::test]
2358 async fn move_page_append_success() {
2359 let server = wiremock::MockServer::start().await;
2360
2361 wiremock::Mock::given(wiremock::matchers::method("PUT"))
2362 .and(wiremock::matchers::path(
2363 "/wiki/rest/api/content/12345/move/append/456",
2364 ))
2365 .respond_with(
2366 wiremock::ResponseTemplate::new(200)
2367 .set_body_json(serde_json::json!({"pageId": "12345"})),
2368 )
2369 .expect(1)
2370 .mount(&server)
2371 .await;
2372 mount_ancestor_fetch(&server, "12345", "456").await;
2373
2374 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2375 let api = ConfluenceApi::new(client);
2376 let moved = api
2377 .move_page("12345", "456", MovePosition::Append)
2378 .await
2379 .unwrap();
2380 assert_eq!(moved.id, "12345");
2381 assert_eq!(moved.title, "Moved Page");
2382 assert_eq!(moved.parent_id.as_deref(), Some("456"));
2383 assert_eq!(moved.ancestors, vec!["10".to_string(), "456".to_string()]);
2384 }
2385
2386 #[tokio::test]
2387 async fn move_page_before_success() {
2388 let server = wiremock::MockServer::start().await;
2389
2390 wiremock::Mock::given(wiremock::matchers::method("PUT"))
2391 .and(wiremock::matchers::path(
2392 "/wiki/rest/api/content/12345/move/before/456",
2393 ))
2394 .respond_with(
2395 wiremock::ResponseTemplate::new(200)
2396 .set_body_json(serde_json::json!({"pageId": "12345"})),
2397 )
2398 .expect(1)
2399 .mount(&server)
2400 .await;
2401 mount_ancestor_fetch(&server, "12345", "789").await;
2402
2403 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2404 let api = ConfluenceApi::new(client);
2405 let moved = api
2406 .move_page("12345", "456", MovePosition::Before)
2407 .await
2408 .unwrap();
2409 assert_eq!(moved.parent_id.as_deref(), Some("789"));
2410 }
2411
2412 #[tokio::test]
2413 async fn move_page_after_success() {
2414 let server = wiremock::MockServer::start().await;
2415
2416 wiremock::Mock::given(wiremock::matchers::method("PUT"))
2417 .and(wiremock::matchers::path(
2418 "/wiki/rest/api/content/12345/move/after/456",
2419 ))
2420 .respond_with(
2421 wiremock::ResponseTemplate::new(200)
2422 .set_body_json(serde_json::json!({"pageId": "12345"})),
2423 )
2424 .expect(1)
2425 .mount(&server)
2426 .await;
2427 mount_ancestor_fetch(&server, "12345", "789").await;
2428
2429 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2430 let api = ConfluenceApi::new(client);
2431 let moved = api
2432 .move_page("12345", "456", MovePosition::After)
2433 .await
2434 .unwrap();
2435 assert_eq!(moved.id, "12345");
2436 }
2437
2438 #[tokio::test]
2439 async fn move_page_forbidden_surfaces_reason() {
2440 let server = wiremock::MockServer::start().await;
2441
2442 wiremock::Mock::given(wiremock::matchers::method("PUT"))
2443 .and(wiremock::matchers::path(
2444 "/wiki/rest/api/content/12345/move/append/456",
2445 ))
2446 .respond_with(
2447 wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
2448 "errors": [{"detail": "User cannot move page"}]
2449 })),
2450 )
2451 .expect(1)
2452 .mount(&server)
2453 .await;
2454
2455 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2456 let api = ConfluenceApi::new(client);
2457 let err = api
2458 .move_page("12345", "456", MovePosition::Append)
2459 .await
2460 .unwrap_err();
2461 let msg = err.to_string();
2462 assert!(msg.contains("insufficient permissions"));
2463 assert!(msg.contains("User cannot move page"));
2464 }
2465
2466 #[tokio::test]
2467 async fn move_page_not_found() {
2468 let server = wiremock::MockServer::start().await;
2469
2470 wiremock::Mock::given(wiremock::matchers::method("PUT"))
2471 .and(wiremock::matchers::path(
2472 "/wiki/rest/api/content/99999/move/append/456",
2473 ))
2474 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Page not found"))
2475 .expect(1)
2476 .mount(&server)
2477 .await;
2478
2479 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2480 let api = ConfluenceApi::new(client);
2481 let err = api
2482 .move_page("99999", "456", MovePosition::Append)
2483 .await
2484 .unwrap_err();
2485 let msg = err.to_string();
2486 assert!(msg.contains("not found"));
2487 assert!(msg.contains("Page not found"));
2488 }
2489
2490 #[tokio::test]
2491 async fn move_page_other_error_falls_through_to_generic() {
2492 let server = wiremock::MockServer::start().await;
2493
2494 wiremock::Mock::given(wiremock::matchers::method("PUT"))
2495 .and(wiremock::matchers::path(
2496 "/wiki/rest/api/content/12345/move/append/456",
2497 ))
2498 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
2499 .expect(1)
2500 .mount(&server)
2501 .await;
2502
2503 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2504 let api = ConfluenceApi::new(client);
2505 let err = api
2506 .move_page("12345", "456", MovePosition::Append)
2507 .await
2508 .unwrap_err();
2509 assert!(err.to_string().contains("500"));
2510 }
2511
2512 #[tokio::test]
2513 async fn move_page_ancestor_fetch_failure_is_propagated() {
2514 let server = wiremock::MockServer::start().await;
2515
2516 wiremock::Mock::given(wiremock::matchers::method("PUT"))
2517 .and(wiremock::matchers::path(
2518 "/wiki/rest/api/content/12345/move/append/456",
2519 ))
2520 .respond_with(
2521 wiremock::ResponseTemplate::new(200)
2522 .set_body_json(serde_json::json!({"pageId": "12345"})),
2523 )
2524 .expect(1)
2525 .mount(&server)
2526 .await;
2527 wiremock::Mock::given(wiremock::matchers::method("GET"))
2528 .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
2529 .and(wiremock::matchers::query_param("include-ancestors", "true"))
2530 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("ancestor boom"))
2531 .expect(1)
2532 .mount(&server)
2533 .await;
2534
2535 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2536 let api = ConfluenceApi::new(client);
2537 let err = api
2538 .move_page("12345", "456", MovePosition::Append)
2539 .await
2540 .unwrap_err();
2541 assert!(err.to_string().contains("500"));
2542 }
2543
2544 #[tokio::test]
2545 async fn get_children_success() {
2546 let server = wiremock::MockServer::start().await;
2547
2548 wiremock::Mock::given(wiremock::matchers::method("GET"))
2549 .and(wiremock::matchers::path(
2550 "/wiki/rest/api/content/12345/child/page",
2551 ))
2552 .respond_with(
2553 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2554 "results": [
2555 {"id": "111", "title": "Child One"},
2556 {"id": "222", "title": "Child Two"}
2557 ]
2558 })),
2559 )
2560 .expect(1)
2561 .mount(&server)
2562 .await;
2563
2564 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2565 let api = ConfluenceApi::new(client);
2566 let children = api.get_children("12345").await.unwrap();
2567
2568 assert_eq!(children.len(), 2);
2569 assert_eq!(children[0].id, "111");
2570 assert_eq!(children[0].title, "Child One");
2571 assert_eq!(children[1].id, "222");
2572 }
2573
2574 #[tokio::test]
2575 async fn get_children_empty() {
2576 let server = wiremock::MockServer::start().await;
2577
2578 wiremock::Mock::given(wiremock::matchers::method("GET"))
2579 .and(wiremock::matchers::path(
2580 "/wiki/rest/api/content/12345/child/page",
2581 ))
2582 .respond_with(
2583 wiremock::ResponseTemplate::new(200)
2584 .set_body_json(serde_json::json!({"results": []})),
2585 )
2586 .expect(1)
2587 .mount(&server)
2588 .await;
2589
2590 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2591 let api = ConfluenceApi::new(client);
2592 let children = api.get_children("12345").await.unwrap();
2593 assert!(children.is_empty());
2594 }
2595
2596 #[tokio::test]
2597 async fn get_children_pagination() {
2598 let server = wiremock::MockServer::start().await;
2599
2600 wiremock::Mock::given(wiremock::matchers::method("GET"))
2601 .and(wiremock::matchers::path(
2602 "/wiki/rest/api/content/12345/child/page",
2603 ))
2604 .and(wiremock::matchers::query_param_is_missing("start"))
2605 .respond_with(
2606 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2607 "results": [{"id": "111", "title": "First", "status": "current"}],
2608 "_links": {
2609 "next": "/wiki/rest/api/content/12345/child/page?limit=50&start=50"
2610 }
2611 })),
2612 )
2613 .expect(1)
2614 .mount(&server)
2615 .await;
2616
2617 wiremock::Mock::given(wiremock::matchers::method("GET"))
2618 .and(wiremock::matchers::path(
2619 "/wiki/rest/api/content/12345/child/page",
2620 ))
2621 .and(wiremock::matchers::query_param("start", "50"))
2622 .respond_with(
2623 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2624 "results": [{"id": "222", "title": "Second", "status": "current"}]
2625 })),
2626 )
2627 .expect(1)
2628 .mount(&server)
2629 .await;
2630
2631 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2632 let api = ConfluenceApi::new(client);
2633 let children = api.get_children("12345").await.unwrap();
2634 assert_eq!(children.len(), 2);
2635 assert_eq!(children[0].status, "current");
2636 assert_eq!(children[0].parent_id.as_deref(), Some("12345"));
2637 }
2638
2639 #[tokio::test]
2640 async fn get_space_root_pages_success() {
2641 let server = wiremock::MockServer::start().await;
2642
2643 wiremock::Mock::given(wiremock::matchers::method("GET"))
2644 .and(wiremock::matchers::path("/wiki/api/v2/spaces/98765/pages"))
2645 .and(wiremock::matchers::query_param("depth", "root"))
2646 .respond_with(
2647 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2648 "results": [
2649 {"id": "111", "title": "Top One", "status": "current"},
2650 {"id": "222", "title": "Top Two", "status": "draft", "parentId": null}
2651 ]
2652 })),
2653 )
2654 .expect(1)
2655 .mount(&server)
2656 .await;
2657
2658 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2659 let api = ConfluenceApi::new(client);
2660 let pages = api.get_space_root_pages("98765").await.unwrap();
2661 assert_eq!(pages.len(), 2);
2662 assert_eq!(pages[0].id, "111");
2663 assert_eq!(pages[0].status, "current");
2664 assert_eq!(pages[1].status, "draft");
2665 }
2666
2667 #[tokio::test]
2668 async fn get_space_root_pages_empty() {
2669 let server = wiremock::MockServer::start().await;
2670
2671 wiremock::Mock::given(wiremock::matchers::method("GET"))
2672 .and(wiremock::matchers::path("/wiki/api/v2/spaces/98765/pages"))
2673 .respond_with(
2674 wiremock::ResponseTemplate::new(200)
2675 .set_body_json(serde_json::json!({"results": []})),
2676 )
2677 .expect(1)
2678 .mount(&server)
2679 .await;
2680
2681 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2682 let api = ConfluenceApi::new(client);
2683 let pages = api.get_space_root_pages("98765").await.unwrap();
2684 assert!(pages.is_empty());
2685 }
2686
2687 #[tokio::test]
2688 async fn get_space_root_pages_api_error() {
2689 let server = wiremock::MockServer::start().await;
2690
2691 wiremock::Mock::given(wiremock::matchers::method("GET"))
2692 .and(wiremock::matchers::path("/wiki/api/v2/spaces/99999/pages"))
2693 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
2694 .expect(1)
2695 .mount(&server)
2696 .await;
2697
2698 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2699 let api = ConfluenceApi::new(client);
2700 let err = api.get_space_root_pages("99999").await.unwrap_err();
2701 assert!(err.to_string().contains("403"));
2702 }
2703
2704 #[tokio::test]
2705 async fn get_space_root_pages_pagination() {
2706 let server = wiremock::MockServer::start().await;
2707
2708 wiremock::Mock::given(wiremock::matchers::method("GET"))
2709 .and(wiremock::matchers::path("/wiki/api/v2/spaces/98765/pages"))
2710 .and(wiremock::matchers::query_param_is_missing("cursor"))
2711 .respond_with(
2712 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2713 "results": [{"id": "111", "title": "A", "status": "current"}],
2714 "_links": {
2715 "next": "/wiki/api/v2/spaces/98765/pages?depth=root&cursor=page2"
2716 }
2717 })),
2718 )
2719 .expect(1)
2720 .mount(&server)
2721 .await;
2722
2723 wiremock::Mock::given(wiremock::matchers::method("GET"))
2724 .and(wiremock::matchers::path("/wiki/api/v2/spaces/98765/pages"))
2725 .and(wiremock::matchers::query_param("cursor", "page2"))
2726 .respond_with(
2727 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2728 "results": [{"id": "222", "title": "B", "status": "current"}]
2729 })),
2730 )
2731 .expect(1)
2732 .mount(&server)
2733 .await;
2734
2735 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2736 let api = ConfluenceApi::new(client);
2737 let pages = api.get_space_root_pages("98765").await.unwrap();
2738 assert_eq!(pages.len(), 2);
2739 assert_eq!(pages[0].id, "111");
2740 assert_eq!(pages[1].id, "222");
2741 }
2742
2743 #[tokio::test]
2744 async fn get_children_api_error() {
2745 let server = wiremock::MockServer::start().await;
2746
2747 wiremock::Mock::given(wiremock::matchers::method("GET"))
2748 .and(wiremock::matchers::path(
2749 "/wiki/rest/api/content/99999/child/page",
2750 ))
2751 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
2752 .expect(1)
2753 .mount(&server)
2754 .await;
2755
2756 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2757 let api = ConfluenceApi::new(client);
2758 let err = api.get_children("99999").await.unwrap_err();
2759 assert!(err.to_string().contains("404"));
2760 }
2761
2762 #[tokio::test]
2763 async fn resolve_space_key_fallback_on_error() {
2764 let server = wiremock::MockServer::start().await;
2765
2766 wiremock::Mock::given(wiremock::matchers::method("GET"))
2767 .and(wiremock::matchers::path("/wiki/api/v2/spaces/unknown"))
2768 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
2769 .mount(&server)
2770 .await;
2771
2772 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2773 let api = ConfluenceApi::new(client);
2774 let key = api.resolve_space_key("unknown").await.unwrap();
2775 assert_eq!(key, "unknown");
2777 }
2778
2779 #[tokio::test]
2782 async fn get_page_comments_success() {
2783 let server = wiremock::MockServer::start().await;
2784
2785 wiremock::Mock::given(wiremock::matchers::method("GET"))
2786 .and(wiremock::matchers::path(
2787 "/wiki/api/v2/pages/12345/footer-comments",
2788 ))
2789 .respond_with(
2790 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2791 "results": [
2792 {
2793 "id": "100",
2794 "version": {
2795 "authorId": "user-abc",
2796 "createdAt": "2026-04-01T10:00:00.000Z"
2797 },
2798 "body": {
2799 "atlas_doc_format": {
2800 "value": "{\"version\":1,\"type\":\"doc\",\"content\":[]}"
2801 }
2802 }
2803 },
2804 {
2805 "id": "101",
2806 "version": {
2807 "authorId": "user-def",
2808 "createdAt": "2026-04-02T14:00:00.000Z"
2809 }
2810 }
2811 ]
2812 })),
2813 )
2814 .expect(1)
2815 .mount(&server)
2816 .await;
2817
2818 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2819 let api = ConfluenceApi::new(client);
2820 let comments = api.get_page_comments("12345").await.unwrap();
2821
2822 assert_eq!(comments.len(), 2);
2823 assert_eq!(comments[0].id, "100");
2824 assert_eq!(comments[0].author, "user-abc");
2825 assert!(comments[0].body_adf.is_some());
2826 assert_eq!(comments[1].id, "101");
2827 assert!(comments[1].body_adf.is_none());
2828 }
2829
2830 #[tokio::test]
2831 async fn get_page_comments_malformed_adf_body() {
2832 let server = wiremock::MockServer::start().await;
2833
2834 wiremock::Mock::given(wiremock::matchers::method("GET"))
2835 .and(wiremock::matchers::path(
2836 "/wiki/api/v2/pages/12345/footer-comments",
2837 ))
2838 .respond_with(
2839 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2840 "results": [
2841 {
2842 "id": "100",
2843 "version": {
2844 "authorId": "user-abc",
2845 "createdAt": "2026-04-01T10:00:00.000Z"
2846 },
2847 "body": {
2848 "atlas_doc_format": {
2849 "value": "{ invalid json }"
2850 }
2851 }
2852 }
2853 ]
2854 })),
2855 )
2856 .expect(1)
2857 .mount(&server)
2858 .await;
2859
2860 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2861 let api = ConfluenceApi::new(client);
2862 let comments = api.get_page_comments("12345").await.unwrap();
2863
2864 assert_eq!(comments.len(), 1);
2865 assert_eq!(comments[0].id, "100");
2866 assert!(comments[0].body_adf.is_none());
2868 }
2869
2870 #[tokio::test]
2871 async fn get_page_comments_missing_version() {
2872 let server = wiremock::MockServer::start().await;
2873
2874 wiremock::Mock::given(wiremock::matchers::method("GET"))
2875 .and(wiremock::matchers::path(
2876 "/wiki/api/v2/pages/12345/footer-comments",
2877 ))
2878 .respond_with(
2879 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2880 "results": [
2881 {
2882 "id": "100"
2883 }
2884 ]
2885 })),
2886 )
2887 .expect(1)
2888 .mount(&server)
2889 .await;
2890
2891 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2892 let api = ConfluenceApi::new(client);
2893 let comments = api.get_page_comments("12345").await.unwrap();
2894
2895 assert_eq!(comments.len(), 1);
2896 assert_eq!(comments[0].author, "");
2897 assert_eq!(comments[0].created, "");
2898 assert!(comments[0].body_adf.is_none());
2899 }
2900
2901 #[tokio::test]
2902 async fn get_page_comments_empty() {
2903 let server = wiremock::MockServer::start().await;
2904
2905 wiremock::Mock::given(wiremock::matchers::method("GET"))
2906 .and(wiremock::matchers::path(
2907 "/wiki/api/v2/pages/12345/footer-comments",
2908 ))
2909 .respond_with(
2910 wiremock::ResponseTemplate::new(200)
2911 .set_body_json(serde_json::json!({"results": []})),
2912 )
2913 .expect(1)
2914 .mount(&server)
2915 .await;
2916
2917 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2918 let api = ConfluenceApi::new(client);
2919 let comments = api.get_page_comments("12345").await.unwrap();
2920 assert!(comments.is_empty());
2921 }
2922
2923 #[tokio::test]
2924 async fn get_page_comments_api_error() {
2925 let server = wiremock::MockServer::start().await;
2926
2927 wiremock::Mock::given(wiremock::matchers::method("GET"))
2928 .and(wiremock::matchers::path(
2929 "/wiki/api/v2/pages/99999/footer-comments",
2930 ))
2931 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
2932 .expect(1)
2933 .mount(&server)
2934 .await;
2935
2936 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2937 let api = ConfluenceApi::new(client);
2938 let err = api.get_page_comments("99999").await.unwrap_err();
2939 assert!(err.to_string().contains("404"));
2940 }
2941
2942 #[tokio::test]
2943 async fn get_page_comments_with_pagination() {
2944 let server = wiremock::MockServer::start().await;
2945
2946 wiremock::Mock::given(wiremock::matchers::method("GET"))
2948 .and(wiremock::matchers::path(
2949 "/wiki/api/v2/pages/12345/footer-comments",
2950 ))
2951 .and(wiremock::matchers::query_param_is_missing("cursor"))
2952 .respond_with(
2953 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2954 "results": [
2955 {
2956 "id": "100",
2957 "version": {
2958 "authorId": "user-abc",
2959 "createdAt": "2026-04-01T10:00:00.000Z"
2960 },
2961 "body": {
2962 "atlas_doc_format": {
2963 "value": "{\"version\":1,\"type\":\"doc\",\"content\":[]}"
2964 }
2965 }
2966 }
2967 ],
2968 "_links": {
2969 "next": "/wiki/api/v2/pages/12345/footer-comments?body-format=atlas_doc_format&cursor=page2"
2970 }
2971 })),
2972 )
2973 .expect(1)
2974 .mount(&server)
2975 .await;
2976
2977 wiremock::Mock::given(wiremock::matchers::method("GET"))
2979 .and(wiremock::matchers::path(
2980 "/wiki/api/v2/pages/12345/footer-comments",
2981 ))
2982 .and(wiremock::matchers::query_param("cursor", "page2"))
2983 .respond_with(
2984 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2985 "results": [
2986 {
2987 "id": "101",
2988 "version": {
2989 "authorId": "user-def",
2990 "createdAt": "2026-04-02T14:00:00.000Z"
2991 }
2992 }
2993 ]
2994 })),
2995 )
2996 .expect(1)
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 comments = api.get_page_comments("12345").await.unwrap();
3003
3004 assert_eq!(comments.len(), 2);
3005 assert_eq!(comments[0].id, "100");
3006 assert_eq!(comments[0].author, "user-abc");
3007 assert!(comments[0].body_adf.is_some());
3008 assert_eq!(comments[1].id, "101");
3009 assert_eq!(comments[1].author, "user-def");
3010 }
3011
3012 #[tokio::test]
3013 async fn get_page_comments_pagination_stops_on_empty_page() {
3014 let server = wiremock::MockServer::start().await;
3015
3016 wiremock::Mock::given(wiremock::matchers::method("GET"))
3019 .and(wiremock::matchers::path(
3020 "/wiki/api/v2/pages/12345/footer-comments",
3021 ))
3022 .respond_with(
3023 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3024 "results": [],
3025 "_links": {
3026 "next": "/wiki/api/v2/pages/12345/footer-comments?cursor=loop"
3027 }
3028 })),
3029 )
3030 .expect(1)
3031 .mount(&server)
3032 .await;
3033
3034 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3035 let api = ConfluenceApi::new(client);
3036 let comments = api.get_page_comments("12345").await.unwrap();
3037 assert!(comments.is_empty());
3038 }
3039
3040 #[tokio::test]
3043 async fn add_page_comment_success() {
3044 let server = wiremock::MockServer::start().await;
3045
3046 wiremock::Mock::given(wiremock::matchers::method("POST"))
3047 .and(wiremock::matchers::path("/wiki/api/v2/footer-comments"))
3048 .respond_with(
3049 wiremock::ResponseTemplate::new(200)
3050 .set_body_json(serde_json::json!({"id": "200"})),
3051 )
3052 .expect(1)
3053 .mount(&server)
3054 .await;
3055
3056 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3057 let api = ConfluenceApi::new(client);
3058 let adf = ValidatedAdfDocument::empty();
3059 let result = api.add_page_comment("12345", &adf).await;
3060 assert!(result.is_ok());
3061 }
3062
3063 #[tokio::test]
3064 async fn add_page_comment_api_error() {
3065 let server = wiremock::MockServer::start().await;
3066
3067 wiremock::Mock::given(wiremock::matchers::method("POST"))
3068 .and(wiremock::matchers::path("/wiki/api/v2/footer-comments"))
3069 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
3070 .expect(1)
3071 .mount(&server)
3072 .await;
3073
3074 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3075 let api = ConfluenceApi::new(client);
3076 let adf = ValidatedAdfDocument::empty();
3077 let err = api.add_page_comment("12345", &adf).await.unwrap_err();
3078 assert!(err.to_string().contains("403"));
3079 }
3080
3081 #[tokio::test]
3082 async fn add_page_comment_500_with_panel_expand_diagnoses() {
3083 let server = wiremock::MockServer::start().await;
3084
3085 wiremock::Mock::given(wiremock::matchers::method("POST"))
3086 .and(wiremock::matchers::path("/wiki/api/v2/footer-comments"))
3087 .respond_with(
3088 wiremock::ResponseTemplate::new(500).set_body_string("Internal Server Error"),
3089 )
3090 .expect(1)
3091 .mount(&server)
3092 .await;
3093
3094 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3095 let api = ConfluenceApi::new(client);
3096 let adf = ValidatedAdfDocument::trust(adf_with_panel_expand());
3097 let err = api.add_page_comment("12345", &adf).await.unwrap_err();
3098 let msg = err.to_string();
3099 assert!(msg.contains("HTTP 500"), "missing status in: {msg}");
3100 assert!(
3101 msg.contains("Diagnosis:"),
3102 "missing Diagnosis line in: {msg}"
3103 );
3104 assert!(msg.contains("`expand`"), "missing child name in: {msg}");
3105 assert!(msg.contains("`panel`"), "missing parent name in: {msg}");
3106 assert!(msg.contains("Hint:"), "missing Hint line in: {msg}");
3107 }
3108
3109 #[tokio::test]
3112 async fn get_labels_success() {
3113 let server = wiremock::MockServer::start().await;
3114
3115 wiremock::Mock::given(wiremock::matchers::method("GET"))
3116 .and(wiremock::matchers::path("/wiki/api/v2/pages/12345/labels"))
3117 .respond_with(
3118 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3119 "results": [
3120 {"id": "1", "name": "architecture", "prefix": "global"},
3121 {"id": "2", "name": "draft", "prefix": "global"}
3122 ]
3123 })),
3124 )
3125 .expect(1)
3126 .mount(&server)
3127 .await;
3128
3129 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3130 let api = ConfluenceApi::new(client);
3131 let labels = api.get_labels("12345").await.unwrap();
3132
3133 assert_eq!(labels.len(), 2);
3134 assert_eq!(labels[0].name, "architecture");
3135 assert_eq!(labels[0].prefix, "global");
3136 assert_eq!(labels[1].name, "draft");
3137 }
3138
3139 #[tokio::test]
3140 async fn get_labels_with_pagination() {
3141 let server = wiremock::MockServer::start().await;
3142
3143 wiremock::Mock::given(wiremock::matchers::method("GET"))
3145 .and(wiremock::matchers::path("/wiki/api/v2/pages/12345/labels"))
3146 .and(wiremock::matchers::query_param_is_missing("cursor"))
3147 .respond_with(
3148 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3149 "results": [
3150 {"id": "1", "name": "architecture", "prefix": "global"}
3151 ],
3152 "_links": {
3153 "next": "/wiki/api/v2/pages/12345/labels?cursor=page2"
3154 }
3155 })),
3156 )
3157 .expect(1)
3158 .mount(&server)
3159 .await;
3160
3161 wiremock::Mock::given(wiremock::matchers::method("GET"))
3163 .and(wiremock::matchers::path("/wiki/api/v2/pages/12345/labels"))
3164 .and(wiremock::matchers::query_param("cursor", "page2"))
3165 .respond_with(
3166 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3167 "results": [
3168 {"id": "2", "name": "draft", "prefix": "global"}
3169 ]
3170 })),
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 labels = api.get_labels("12345").await.unwrap();
3179
3180 assert_eq!(labels.len(), 2);
3181 assert_eq!(labels[0].name, "architecture");
3182 assert_eq!(labels[1].name, "draft");
3183 }
3184
3185 #[tokio::test]
3186 async fn get_labels_empty() {
3187 let server = wiremock::MockServer::start().await;
3188
3189 wiremock::Mock::given(wiremock::matchers::method("GET"))
3190 .and(wiremock::matchers::path("/wiki/api/v2/pages/12345/labels"))
3191 .respond_with(
3192 wiremock::ResponseTemplate::new(200)
3193 .set_body_json(serde_json::json!({"results": []})),
3194 )
3195 .expect(1)
3196 .mount(&server)
3197 .await;
3198
3199 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3200 let api = ConfluenceApi::new(client);
3201 let labels = api.get_labels("12345").await.unwrap();
3202 assert!(labels.is_empty());
3203 }
3204
3205 #[tokio::test]
3206 async fn get_labels_api_error() {
3207 let server = wiremock::MockServer::start().await;
3208
3209 wiremock::Mock::given(wiremock::matchers::method("GET"))
3210 .and(wiremock::matchers::path("/wiki/api/v2/pages/99999/labels"))
3211 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
3212 .expect(1)
3213 .mount(&server)
3214 .await;
3215
3216 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3217 let api = ConfluenceApi::new(client);
3218 let err = api.get_labels("99999").await.unwrap_err();
3219 assert!(err.to_string().contains("404"));
3220 }
3221
3222 #[tokio::test]
3225 async fn add_labels_success() {
3226 let server = wiremock::MockServer::start().await;
3227
3228 wiremock::Mock::given(wiremock::matchers::method("POST"))
3229 .and(wiremock::matchers::path(
3230 "/wiki/rest/api/content/12345/label",
3231 ))
3232 .respond_with(
3233 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3234 "results": [
3235 {"prefix": "global", "name": "architecture", "id": "1"},
3236 {"prefix": "global", "name": "draft", "id": "2"}
3237 ]
3238 })),
3239 )
3240 .expect(1)
3241 .mount(&server)
3242 .await;
3243
3244 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3245 let api = ConfluenceApi::new(client);
3246 let result = api
3247 .add_labels("12345", &["architecture".to_string(), "draft".to_string()])
3248 .await;
3249 assert!(result.is_ok());
3250 }
3251
3252 #[tokio::test]
3253 async fn add_labels_api_error() {
3254 let server = wiremock::MockServer::start().await;
3255
3256 wiremock::Mock::given(wiremock::matchers::method("POST"))
3257 .and(wiremock::matchers::path(
3258 "/wiki/rest/api/content/99999/label",
3259 ))
3260 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
3261 .expect(1)
3262 .mount(&server)
3263 .await;
3264
3265 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3266 let api = ConfluenceApi::new(client);
3267 let err = api
3268 .add_labels("99999", &["test".to_string()])
3269 .await
3270 .unwrap_err();
3271 assert!(err.to_string().contains("404"));
3272 }
3273
3274 #[tokio::test]
3277 async fn remove_label_success() {
3278 let server = wiremock::MockServer::start().await;
3279
3280 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
3281 .and(wiremock::matchers::path(
3282 "/wiki/rest/api/content/12345/label/architecture",
3283 ))
3284 .respond_with(wiremock::ResponseTemplate::new(204))
3285 .expect(1)
3286 .mount(&server)
3287 .await;
3288
3289 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3290 let api = ConfluenceApi::new(client);
3291 let result = api.remove_label("12345", "architecture").await;
3292 assert!(result.is_ok());
3293 }
3294
3295 #[tokio::test]
3296 async fn remove_label_api_error() {
3297 let server = wiremock::MockServer::start().await;
3298
3299 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
3300 .and(wiremock::matchers::path(
3301 "/wiki/rest/api/content/99999/label/missing",
3302 ))
3303 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
3304 .expect(1)
3305 .mount(&server)
3306 .await;
3307
3308 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3309 let api = ConfluenceApi::new(client);
3310 let err = api.remove_label("99999", "missing").await.unwrap_err();
3311 assert!(err.to_string().contains("404"));
3312 }
3313
3314 #[test]
3317 fn confluence_label_entry_deserialization() {
3318 let json = r#"{"id": "1", "name": "architecture", "prefix": "global"}"#;
3319 let entry: ConfluenceLabelEntry = serde_json::from_str(json).unwrap();
3320 assert_eq!(entry.id, "1");
3321 assert_eq!(entry.name, "architecture");
3322 assert_eq!(entry.prefix, "global");
3323 }
3324
3325 #[test]
3326 fn confluence_add_label_entry_serialization() {
3327 let entry = ConfluenceAddLabelEntry {
3328 prefix: "global".to_string(),
3329 name: "test".to_string(),
3330 };
3331 let json = serde_json::to_value(&entry).unwrap();
3332 assert_eq!(json["prefix"], "global");
3333 assert_eq!(json["name"], "test");
3334 }
3335
3336 #[test]
3339 fn since_filter_parse_numeric() {
3340 assert_eq!(SinceFilter::parse("5").unwrap(), SinceFilter::Version(5));
3341 assert_eq!(SinceFilter::parse("0").unwrap(), SinceFilter::Version(0));
3342 }
3343
3344 #[test]
3345 fn since_filter_parse_iso_date() {
3346 let f = SinceFilter::parse("2026-01-01T00:00:00Z").unwrap();
3347 assert_eq!(
3348 f,
3349 SinceFilter::CreatedAt("2026-01-01T00:00:00Z".to_string())
3350 );
3351 }
3352
3353 #[test]
3356 fn extract_cursor_extracts_value() {
3357 let next = "/wiki/api/v2/pages/12345/attachments?cursor=abc123&limit=25";
3358 assert_eq!(extract_cursor_from_next(next), Some("abc123".to_string()));
3359 }
3360
3361 #[test]
3362 fn extract_cursor_returns_none_when_absent() {
3363 assert_eq!(
3364 extract_cursor_from_next("/wiki/api/v2/pages/12345/attachments?limit=25"),
3365 None
3366 );
3367 }
3368
3369 #[test]
3370 fn since_filter_parse_iso_date_no_time() {
3371 let f = SinceFilter::parse("2026-01-01").unwrap();
3372 assert_eq!(f, SinceFilter::CreatedAt("2026-01-01".to_string()));
3373 }
3374
3375 #[test]
3376 fn since_filter_parse_trims_whitespace() {
3377 assert_eq!(
3378 SinceFilter::parse(" 7 ").unwrap(),
3379 SinceFilter::Version(7)
3380 );
3381 }
3382
3383 #[test]
3384 fn extract_cursor_decodes_percent_encoded() {
3385 assert_eq!(
3386 extract_cursor_from_next("/wiki/api/v2/pages/1/attachments?cursor=foo%3Dbar"),
3387 Some("foo=bar".to_string())
3388 );
3389 }
3390
3391 #[test]
3392 fn since_filter_parse_empty_rejected() {
3393 assert!(SinceFilter::parse("").is_err());
3394 assert!(SinceFilter::parse(" ").is_err());
3395 }
3396
3397 #[test]
3398 fn since_filter_parse_garbage_rejected() {
3399 assert!(SinceFilter::parse("nope").is_err());
3400 }
3401
3402 #[test]
3403 fn since_filter_matches_version() {
3404 let v = PageVersion {
3405 number: 5,
3406 created_at: String::new(),
3407 author_id: String::new(),
3408 message: String::new(),
3409 minor_edit: false,
3410 };
3411 assert!(SinceFilter::Version(5).matches(&v));
3412 assert!(SinceFilter::Version(4).matches(&v));
3413 assert!(!SinceFilter::Version(6).matches(&v));
3414 }
3415
3416 #[test]
3417 fn since_filter_matches_created_at() {
3418 let v = PageVersion {
3419 number: 1,
3420 created_at: "2026-05-01T00:00:00Z".to_string(),
3421 author_id: String::new(),
3422 message: String::new(),
3423 minor_edit: false,
3424 };
3425 assert!(SinceFilter::CreatedAt("2026-04-01T00:00:00Z".to_string()).matches(&v));
3426 assert!(SinceFilter::CreatedAt("2026-05-01T00:00:00Z".to_string()).matches(&v));
3427 assert!(!SinceFilter::CreatedAt("2026-06-01T00:00:00Z".to_string()).matches(&v));
3428 }
3429
3430 #[test]
3431 fn since_filter_created_at_treats_empty_as_too_old() {
3432 let v = PageVersion {
3433 number: 1,
3434 created_at: String::new(),
3435 author_id: String::new(),
3436 message: String::new(),
3437 minor_edit: false,
3438 };
3439 assert!(!SinceFilter::CreatedAt("2026-01-01".to_string()).matches(&v));
3440 }
3441
3442 #[test]
3445 fn version_entry_deserialization_full() {
3446 let json = r#"{
3447 "number": 9,
3448 "createdAt": "2026-05-08T10:23:11Z",
3449 "message": "Updated DB version",
3450 "minorEdit": false,
3451 "authorId": "abc-123"
3452 }"#;
3453 let entry: ConfluenceVersionEntry = serde_json::from_str(json).unwrap();
3454 assert_eq!(entry.number, 9);
3455 assert_eq!(entry.created_at.as_deref(), Some("2026-05-08T10:23:11Z"));
3456 assert_eq!(entry.message.as_deref(), Some("Updated DB version"));
3457 assert_eq!(entry.minor_edit, Some(false));
3458 assert_eq!(entry.author_id.as_deref(), Some("abc-123"));
3459 }
3460
3461 #[test]
3462 fn version_entry_deserialization_sparse() {
3463 let json = r#"{"number": 1}"#;
3464 let entry: ConfluenceVersionEntry = serde_json::from_str(json).unwrap();
3465 assert_eq!(entry.number, 1);
3466 assert!(entry.created_at.is_none());
3467 assert!(entry.message.is_none());
3468 assert!(entry.minor_edit.is_none());
3469 assert!(entry.author_id.is_none());
3470 }
3471
3472 #[tokio::test]
3475 async fn get_page_metadata_success() {
3476 let server = wiremock::MockServer::start().await;
3477 wiremock::Mock::given(wiremock::matchers::method("GET"))
3478 .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
3479 .respond_with(
3480 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3481 "id": "12345",
3482 "title": "Hello",
3483 "status": "current",
3484 "spaceId": "1",
3485 "version": {"number": 7}
3486 })),
3487 )
3488 .mount(&server)
3489 .await;
3490
3491 let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
3492 let api = ConfluenceApi::new(client);
3493 let meta = api.get_page_metadata("12345").await.unwrap();
3494 assert_eq!(meta.id, "12345");
3495 assert_eq!(meta.title, "Hello");
3496 assert_eq!(meta.current_version, Some(7));
3497 }
3498
3499 #[tokio::test]
3500 async fn get_page_metadata_no_version() {
3501 let server = wiremock::MockServer::start().await;
3502 wiremock::Mock::given(wiremock::matchers::method("GET"))
3503 .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
3504 .respond_with(
3505 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3506 "id": "12345",
3507 "title": "Hello",
3508 "status": "current",
3509 "spaceId": "1"
3510 })),
3511 )
3512 .mount(&server)
3513 .await;
3514
3515 let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
3516 let api = ConfluenceApi::new(client);
3517 let meta = api.get_page_metadata("12345").await.unwrap();
3518 assert_eq!(meta.current_version, None);
3519 }
3520
3521 #[tokio::test]
3522 async fn get_page_metadata_api_error() {
3523 let server = wiremock::MockServer::start().await;
3524 wiremock::Mock::given(wiremock::matchers::method("GET"))
3525 .and(wiremock::matchers::path("/wiki/api/v2/pages/99999"))
3526 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
3527 .mount(&server)
3528 .await;
3529
3530 let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
3531 let api = ConfluenceApi::new(client);
3532 let err = api.get_page_metadata("99999").await.unwrap_err();
3533 assert!(err.to_string().contains("404"));
3534 }
3535
3536 fn version_json(
3539 number: u32,
3540 created: &str,
3541 author: &str,
3542 msg: &str,
3543 minor: bool,
3544 ) -> serde_json::Value {
3545 serde_json::json!({
3546 "number": number,
3547 "createdAt": created,
3548 "message": msg,
3549 "minorEdit": minor,
3550 "authorId": author
3551 })
3552 }
3553
3554 #[tokio::test]
3555 async fn list_page_versions_single_page() {
3556 let server = wiremock::MockServer::start().await;
3557 wiremock::Mock::given(wiremock::matchers::method("GET"))
3558 .and(wiremock::matchers::path("/wiki/api/v2/pages/12/versions"))
3559 .respond_with(
3560 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3561 "results": [
3562 version_json(3, "2026-05-08T10:00:00Z", "a", "third", false),
3563 version_json(2, "2026-05-07T10:00:00Z", "b", "second", true),
3564 version_json(1, "2026-05-06T10:00:00Z", "c", "first", false),
3565 ]
3566 })),
3567 )
3568 .mount(&server)
3569 .await;
3570
3571 let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
3572 let api = ConfluenceApi::new(client);
3573 let (versions, truncated) = api.list_page_versions("12", None, 0).await.unwrap();
3574 assert_eq!(versions.len(), 3);
3575 assert!(!truncated);
3576 assert_eq!(versions[0].number, 3);
3577 assert_eq!(versions[0].author_id, "a");
3578 assert_eq!(versions[0].message, "third");
3579 assert!(versions[1].minor_edit);
3580 }
3581
3582 #[tokio::test]
3583 async fn list_page_versions_paginates_until_exhausted() {
3584 let server = wiremock::MockServer::start().await;
3585 wiremock::Mock::given(wiremock::matchers::method("GET"))
3587 .and(wiremock::matchers::path("/wiki/api/v2/pages/12/versions"))
3588 .and(wiremock::matchers::query_param("limit", "100"))
3589 .respond_with(
3590 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3591 "results": [
3592 version_json(4, "2026-05-08T10:00:00Z", "a", "four", false),
3593 version_json(3, "2026-05-07T10:00:00Z", "b", "three", false),
3594 ],
3595 "_links": {"next": "/wiki/api/v2/pages/12/versions?cursor=abc"}
3596 })),
3597 )
3598 .mount(&server)
3599 .await;
3600
3601 wiremock::Mock::given(wiremock::matchers::method("GET"))
3602 .and(wiremock::matchers::path("/wiki/api/v2/pages/12/versions"))
3603 .and(wiremock::matchers::query_param("cursor", "abc"))
3604 .respond_with(
3605 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3606 "results": [
3607 version_json(2, "2026-05-06T10:00:00Z", "c", "two", false),
3608 version_json(1, "2026-05-05T10:00:00Z", "d", "one", false),
3609 ]
3610 })),
3611 )
3612 .mount(&server)
3613 .await;
3614
3615 let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
3616 let api = ConfluenceApi::new(client);
3617 let (versions, truncated) = api.list_page_versions("12", None, 0).await.unwrap();
3618 assert_eq!(
3619 versions.iter().map(|v| v.number).collect::<Vec<_>>(),
3620 vec![4, 3, 2, 1]
3621 );
3622 assert!(!truncated);
3623 }
3624
3625 #[tokio::test]
3626 async fn list_page_versions_limit_truncates() {
3627 let server = wiremock::MockServer::start().await;
3628 wiremock::Mock::given(wiremock::matchers::method("GET"))
3629 .and(wiremock::matchers::path("/wiki/api/v2/pages/12/versions"))
3630 .respond_with(
3631 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3632 "results": [
3633 version_json(5, "2026-05-09T10:00:00Z", "a", "five", false),
3634 version_json(4, "2026-05-08T10:00:00Z", "b", "four", false),
3635 version_json(3, "2026-05-07T10:00:00Z", "c", "three", false),
3636 ]
3637 })),
3638 )
3639 .mount(&server)
3640 .await;
3641
3642 let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
3643 let api = ConfluenceApi::new(client);
3644 let (versions, truncated) = api.list_page_versions("12", None, 2).await.unwrap();
3645 assert_eq!(versions.len(), 2);
3646 assert!(truncated, "limit reached mid-page should mark truncated");
3647 assert_eq!(versions[0].number, 5);
3648 assert_eq!(versions[1].number, 4);
3649 }
3650
3651 #[tokio::test]
3652 async fn list_page_versions_limit_exact_page_with_next_truncated() {
3653 let server = wiremock::MockServer::start().await;
3654 wiremock::Mock::given(wiremock::matchers::method("GET"))
3655 .and(wiremock::matchers::path("/wiki/api/v2/pages/12/versions"))
3656 .respond_with(
3657 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3658 "results": [
3659 version_json(5, "2026-05-09T10:00:00Z", "a", "five", false),
3660 version_json(4, "2026-05-08T10:00:00Z", "b", "four", false),
3661 ],
3662 "_links": {"next": "/wiki/api/v2/pages/12/versions?cursor=z"}
3663 })),
3664 )
3665 .mount(&server)
3666 .await;
3667
3668 let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
3669 let api = ConfluenceApi::new(client);
3670 let (versions, truncated) = api.list_page_versions("12", None, 2).await.unwrap();
3671 assert_eq!(versions.len(), 2);
3672 assert!(truncated);
3673 }
3674
3675 #[tokio::test]
3676 async fn list_page_versions_since_numeric_filter() {
3677 let server = wiremock::MockServer::start().await;
3678 wiremock::Mock::given(wiremock::matchers::method("GET"))
3679 .and(wiremock::matchers::path("/wiki/api/v2/pages/12/versions"))
3680 .respond_with(
3681 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3682 "results": [
3683 version_json(5, "2026-05-09T10:00:00Z", "a", "5", false),
3684 version_json(4, "2026-05-08T10:00:00Z", "b", "4", false),
3685 version_json(3, "2026-05-07T10:00:00Z", "c", "3", false),
3686 version_json(2, "2026-05-06T10:00:00Z", "d", "2", false),
3687 ]
3688 })),
3689 )
3690 .mount(&server)
3691 .await;
3692
3693 let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
3694 let api = ConfluenceApi::new(client);
3695 let filter = SinceFilter::parse("4").unwrap();
3696 let (versions, truncated) = api
3697 .list_page_versions("12", Some(&filter), 0)
3698 .await
3699 .unwrap();
3700 assert_eq!(
3701 versions.iter().map(|v| v.number).collect::<Vec<_>>(),
3702 vec![5, 4]
3703 );
3704 assert!(!truncated);
3705 }
3706
3707 #[tokio::test]
3708 async fn list_page_versions_since_iso_filter() {
3709 let server = wiremock::MockServer::start().await;
3710 wiremock::Mock::given(wiremock::matchers::method("GET"))
3711 .and(wiremock::matchers::path("/wiki/api/v2/pages/12/versions"))
3712 .respond_with(
3713 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3714 "results": [
3715 version_json(3, "2026-05-08T10:00:00Z", "a", "", false),
3716 version_json(2, "2026-04-01T10:00:00Z", "b", "", false),
3717 version_json(1, "2026-03-01T10:00:00Z", "c", "", false),
3718 ]
3719 })),
3720 )
3721 .mount(&server)
3722 .await;
3723
3724 let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
3725 let api = ConfluenceApi::new(client);
3726 let filter = SinceFilter::parse("2026-05-01").unwrap();
3727 let (versions, truncated) = api
3728 .list_page_versions("12", Some(&filter), 0)
3729 .await
3730 .unwrap();
3731 assert_eq!(
3732 versions.iter().map(|v| v.number).collect::<Vec<_>>(),
3733 vec![3]
3734 );
3735 assert!(!truncated);
3736 }
3737
3738 #[tokio::test]
3739 async fn list_page_versions_since_stops_pagination_early() {
3740 let server = wiremock::MockServer::start().await;
3741 wiremock::Mock::given(wiremock::matchers::method("GET"))
3743 .and(wiremock::matchers::path("/wiki/api/v2/pages/12/versions"))
3744 .and(wiremock::matchers::query_param("limit", "100"))
3745 .respond_with(
3746 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3747 "results": [
3748 version_json(5, "2026-05-09T10:00:00Z", "a", "5", false),
3749 version_json(4, "2026-05-08T10:00:00Z", "b", "4", false),
3750 ],
3751 "_links": {"next": "/wiki/api/v2/pages/12/versions?cursor=p2"}
3752 })),
3753 )
3754 .mount(&server)
3755 .await;
3756
3757 wiremock::Mock::given(wiremock::matchers::method("GET"))
3758 .and(wiremock::matchers::path("/wiki/api/v2/pages/12/versions"))
3759 .and(wiremock::matchers::query_param("cursor", "p2"))
3760 .respond_with(
3761 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3762 "results": [
3763 version_json(3, "2026-05-07T10:00:00Z", "c", "3", false),
3764 version_json(2, "2026-04-30T10:00:00Z", "d", "2", false),
3765 ]
3766 })),
3767 )
3768 .mount(&server)
3769 .await;
3770
3771 let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
3772 let api = ConfluenceApi::new(client);
3773 let filter = SinceFilter::parse("2026-05-01").unwrap();
3774 let (versions, truncated) = api
3775 .list_page_versions("12", Some(&filter), 0)
3776 .await
3777 .unwrap();
3778 assert_eq!(
3779 versions.iter().map(|v| v.number).collect::<Vec<_>>(),
3780 vec![5, 4, 3]
3781 );
3782 assert!(!truncated, "since cutoff is a stop, not a truncation");
3783 }
3784
3785 #[tokio::test]
3786 async fn list_page_versions_tolerates_missing_optional_fields() {
3787 let server = wiremock::MockServer::start().await;
3788 wiremock::Mock::given(wiremock::matchers::method("GET"))
3789 .and(wiremock::matchers::path("/wiki/api/v2/pages/12/versions"))
3790 .respond_with(
3791 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3792 "results": [
3793 {"number": 1}
3794 ]
3795 })),
3796 )
3797 .mount(&server)
3798 .await;
3799
3800 let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
3801 let api = ConfluenceApi::new(client);
3802 let (versions, truncated) = api.list_page_versions("12", None, 0).await.unwrap();
3803 assert_eq!(versions.len(), 1);
3804 assert_eq!(versions[0].number, 1);
3805 assert_eq!(versions[0].created_at, "");
3806 assert_eq!(versions[0].author_id, "");
3807 assert_eq!(versions[0].message, "");
3808 assert!(!versions[0].minor_edit);
3809 assert!(!truncated);
3810 }
3811
3812 #[tokio::test]
3813 async fn list_page_versions_empty_result() {
3814 let server = wiremock::MockServer::start().await;
3815 wiremock::Mock::given(wiremock::matchers::method("GET"))
3816 .and(wiremock::matchers::path("/wiki/api/v2/pages/12/versions"))
3817 .respond_with(
3818 wiremock::ResponseTemplate::new(200)
3819 .set_body_json(serde_json::json!({"results": []})),
3820 )
3821 .mount(&server)
3822 .await;
3823
3824 let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
3825 let api = ConfluenceApi::new(client);
3826 let (versions, truncated) = api.list_page_versions("12", None, 0).await.unwrap();
3827 assert!(versions.is_empty());
3828 assert!(!truncated);
3829 }
3830
3831 #[tokio::test]
3832 async fn list_page_versions_api_error() {
3833 let server = wiremock::MockServer::start().await;
3834 wiremock::Mock::given(wiremock::matchers::method("GET"))
3835 .and(wiremock::matchers::path(
3836 "/wiki/api/v2/pages/99999/versions",
3837 ))
3838 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
3839 .mount(&server)
3840 .await;
3841
3842 let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
3843 let api = ConfluenceApi::new(client);
3844 let err = api.list_page_versions("99999", None, 0).await.unwrap_err();
3845 assert!(err.to_string().contains("403"));
3846 }
3847
3848 #[tokio::test]
3849 async fn list_page_versions_uses_limit_as_page_size_when_small() {
3850 let server = wiremock::MockServer::start().await;
3851 wiremock::Mock::given(wiremock::matchers::method("GET"))
3852 .and(wiremock::matchers::path("/wiki/api/v2/pages/12/versions"))
3853 .and(wiremock::matchers::query_param("limit", "5"))
3854 .respond_with(
3855 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3856 "results": [
3857 version_json(1, "2026-05-01T00:00:00Z", "a", "", false),
3858 ]
3859 })),
3860 )
3861 .mount(&server)
3862 .await;
3863
3864 let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
3865 let api = ConfluenceApi::new(client);
3866 let (versions, _) = api.list_page_versions("12", None, 5).await.unwrap();
3867 assert_eq!(versions.len(), 1);
3868 }
3869
3870 #[test]
3871 fn urlencoding_escapes_reserved_chars() {
3872 assert_eq!(urlencoding("a=b&c+d %e#"), "a%3Db%26c%2Bd%20%25e%23");
3873 }
3874
3875 #[tokio::test]
3876 async fn upload_attachment_success() {
3877 use tempfile::NamedTempFile;
3878 use tokio::io::AsyncWriteExt;
3879
3880 let server = wiremock::MockServer::start().await;
3881
3882 wiremock::Mock::given(wiremock::matchers::method("POST"))
3883 .and(wiremock::matchers::path(
3884 "/wiki/api/v2/pages/12345/attachments",
3885 ))
3886 .and(wiremock::matchers::header("X-Atlassian-Token", "no-check"))
3887 .respond_with(
3888 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3889 "results": [{
3890 "id": "att-1",
3891 "title": "hello.txt",
3892 "mediaType": "text/plain",
3893 "fileSize": 13,
3894 "downloadLink": "/download/att-1",
3895 "version": {"number": 1},
3896 "pageId": "12345",
3897 "fileId": "f-1"
3898 }]
3899 })),
3900 )
3901 .expect(1)
3902 .mount(&server)
3903 .await;
3904
3905 let mut tmp = tokio::fs::File::from_std(NamedTempFile::new().unwrap().into_file());
3906 tmp.write_all(b"hello, world!").await.unwrap();
3907 tmp.flush().await.unwrap();
3908
3909 let dir = tempfile::tempdir().unwrap();
3911 let path = dir.path().join("hello.txt");
3912 tokio::fs::write(&path, b"hello, world!").await.unwrap();
3913
3914 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3915 let api = ConfluenceApi::new(client);
3916 let attachment = api
3917 .upload_attachment("12345", &path, None, Some("v1"), false)
3918 .await
3919 .unwrap();
3920
3921 assert_eq!(attachment.id, "att-1");
3922 assert_eq!(attachment.title, "hello.txt");
3923 assert_eq!(attachment.media_type.as_deref(), Some("text/plain"));
3924 assert_eq!(attachment.file_size, Some(13));
3925 assert_eq!(attachment.version, Some(1));
3926 }
3927
3928 #[tokio::test]
3929 async fn upload_attachment_page_not_found() {
3930 let server = wiremock::MockServer::start().await;
3931
3932 wiremock::Mock::given(wiremock::matchers::method("POST"))
3933 .and(wiremock::matchers::path(
3934 "/wiki/api/v2/pages/99999/attachments",
3935 ))
3936 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
3937 .expect(1)
3938 .mount(&server)
3939 .await;
3940
3941 let dir = tempfile::tempdir().unwrap();
3942 let path = dir.path().join("x.bin");
3943 tokio::fs::write(&path, b"x").await.unwrap();
3944
3945 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3946 let api = ConfluenceApi::new(client);
3947 let err = api
3948 .upload_attachment("99999", &path, None, None, false)
3949 .await
3950 .unwrap_err();
3951 assert!(err.to_string().contains("404"));
3952 }
3953
3954 #[tokio::test]
3955 async fn upload_attachment_too_large() {
3956 let server = wiremock::MockServer::start().await;
3957
3958 wiremock::Mock::given(wiremock::matchers::method("POST"))
3959 .and(wiremock::matchers::path(
3960 "/wiki/api/v2/pages/12345/attachments",
3961 ))
3962 .respond_with(
3963 wiremock::ResponseTemplate::new(413).set_body_string("Request entity too large"),
3964 )
3965 .expect(1)
3966 .mount(&server)
3967 .await;
3968
3969 let dir = tempfile::tempdir().unwrap();
3970 let path = dir.path().join("big.bin");
3971 tokio::fs::write(&path, b"x").await.unwrap();
3972
3973 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
3974 let api = ConfluenceApi::new(client);
3975 let err = api
3976 .upload_attachment("12345", &path, None, None, false)
3977 .await
3978 .unwrap_err();
3979 let msg = err.to_string();
3980 assert!(msg.contains("413"));
3981 assert!(msg.contains("Request entity too large"));
3982 }
3983
3984 #[tokio::test]
3985 async fn upload_attachment_overrides_filename() {
3986 let server = wiremock::MockServer::start().await;
3987
3988 wiremock::Mock::given(wiremock::matchers::method("POST"))
3989 .and(wiremock::matchers::path(
3990 "/wiki/api/v2/pages/12345/attachments",
3991 ))
3992 .respond_with(
3993 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3994 "results": [{"id": "a", "title": "renamed.png"}]
3995 })),
3996 )
3997 .expect(1)
3998 .mount(&server)
3999 .await;
4000
4001 let dir = tempfile::tempdir().unwrap();
4002 let path = dir.path().join("source.bin");
4003 tokio::fs::write(&path, b"data").await.unwrap();
4004
4005 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4006 let api = ConfluenceApi::new(client);
4007 let attachment = api
4008 .upload_attachment("12345", &path, Some("renamed.png"), None, true)
4009 .await
4010 .unwrap();
4011 assert_eq!(attachment.title, "renamed.png");
4012 }
4013
4014 #[tokio::test]
4015 async fn list_attachments_success() {
4016 let server = wiremock::MockServer::start().await;
4017
4018 wiremock::Mock::given(wiremock::matchers::method("GET"))
4019 .and(wiremock::matchers::path(
4020 "/wiki/api/v2/pages/12345/attachments",
4021 ))
4022 .and(wiremock::matchers::query_param("limit", "25"))
4023 .respond_with(
4024 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
4025 "results": [
4026 {"id": "a1", "title": "one.png", "mediaType": "image/png", "fileSize": 100, "version": {"number": 1}},
4027 {"id": "a2", "title": "two.pdf", "mediaType": "application/pdf"}
4028 ],
4029 "_links": {"next": "/wiki/api/v2/pages/12345/attachments?cursor=NEXT&limit=25"}
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 page = api.list_attachments("12345", None, 25).await.unwrap();
4039 assert_eq!(page.results.len(), 2);
4040 assert_eq!(page.results[0].id, "a1");
4041 assert_eq!(page.results[0].file_size, Some(100));
4042 assert_eq!(page.next_cursor.as_deref(), Some("NEXT"));
4043 }
4044
4045 #[tokio::test]
4046 async fn list_attachments_no_more_pages() {
4047 let server = wiremock::MockServer::start().await;
4048
4049 wiremock::Mock::given(wiremock::matchers::method("GET"))
4050 .and(wiremock::matchers::path(
4051 "/wiki/api/v2/pages/12345/attachments",
4052 ))
4053 .respond_with(
4054 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
4055 "results": []
4056 })),
4057 )
4058 .expect(1)
4059 .mount(&server)
4060 .await;
4061
4062 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4063 let api = ConfluenceApi::new(client);
4064 let page = api.list_attachments("12345", None, 25).await.unwrap();
4065 assert!(page.results.is_empty());
4066 assert!(page.next_cursor.is_none());
4067 }
4068
4069 #[tokio::test]
4070 async fn list_attachments_page_not_found() {
4071 let server = wiremock::MockServer::start().await;
4072
4073 wiremock::Mock::given(wiremock::matchers::method("GET"))
4074 .and(wiremock::matchers::path(
4075 "/wiki/api/v2/pages/99999/attachments",
4076 ))
4077 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
4078 .expect(1)
4079 .mount(&server)
4080 .await;
4081
4082 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4083 let api = ConfluenceApi::new(client);
4084 let err = api.list_attachments("99999", None, 25).await.unwrap_err();
4085 assert!(err.to_string().contains("404"));
4086 }
4087
4088 #[tokio::test]
4089 async fn list_attachments_pagination_round_trip() {
4090 let server = wiremock::MockServer::start().await;
4091
4092 wiremock::Mock::given(wiremock::matchers::method("GET"))
4093 .and(wiremock::matchers::path(
4094 "/wiki/api/v2/pages/12345/attachments",
4095 ))
4096 .and(wiremock::matchers::query_param("cursor", "PAGE2"))
4097 .respond_with(
4098 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
4099 "results": [{"id": "a3", "title": "three.bin"}]
4100 })),
4101 )
4102 .expect(1)
4103 .mount(&server)
4104 .await;
4105
4106 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4107 let api = ConfluenceApi::new(client);
4108 let page = api
4109 .list_attachments("12345", Some("PAGE2"), 25)
4110 .await
4111 .unwrap();
4112 assert_eq!(page.results.len(), 1);
4113 assert_eq!(page.results[0].id, "a3");
4114 }
4115
4116 #[tokio::test]
4117 async fn delete_attachment_success() {
4118 let server = wiremock::MockServer::start().await;
4119
4120 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
4121 .and(wiremock::matchers::path("/wiki/api/v2/attachments/att-1"))
4122 .respond_with(wiremock::ResponseTemplate::new(204))
4123 .expect(1)
4124 .mount(&server)
4125 .await;
4126
4127 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4128 let api = ConfluenceApi::new(client);
4129 assert!(api.delete_attachment("att-1", false).await.is_ok());
4130 }
4131
4132 #[tokio::test]
4133 async fn delete_attachment_with_purge() {
4134 let server = wiremock::MockServer::start().await;
4135
4136 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
4137 .and(wiremock::matchers::path("/wiki/api/v2/attachments/att-1"))
4138 .and(wiremock::matchers::query_param("purge", "true"))
4139 .respond_with(wiremock::ResponseTemplate::new(204))
4140 .expect(1)
4141 .mount(&server)
4142 .await;
4143
4144 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4145 let api = ConfluenceApi::new(client);
4146 assert!(api.delete_attachment("att-1", true).await.is_ok());
4147 }
4148
4149 #[tokio::test]
4150 async fn delete_attachment_not_found() {
4151 let server = wiremock::MockServer::start().await;
4152
4153 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
4154 .and(wiremock::matchers::path("/wiki/api/v2/attachments/missing"))
4155 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
4156 .expect(1)
4157 .mount(&server)
4158 .await;
4159
4160 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
4161 let api = ConfluenceApi::new(client);
4162 let err = api.delete_attachment("missing", false).await.unwrap_err();
4163 assert!(err.to_string().contains("404"));
4164 }
4165
4166 #[test]
4167 fn confluence_attachment_serialize_skips_none_fields() {
4168 let attachment = ConfluenceAttachment {
4169 id: "att-1".to_string(),
4170 title: "x.txt".to_string(),
4171 media_type: None,
4172 file_size: None,
4173 download_url: None,
4174 version: None,
4175 page_id: None,
4176 file_id: None,
4177 };
4178 let json = serde_json::to_value(&attachment).unwrap();
4179 assert_eq!(json["id"], "att-1");
4180 assert_eq!(json["title"], "x.txt");
4181 assert!(json.get("media_type").is_none());
4183 assert!(json.get("file_size").is_none());
4184 assert!(json.get("download_url").is_none());
4185 assert!(json.get("version").is_none());
4186 assert!(json.get("page_id").is_none());
4187 assert!(json.get("file_id").is_none());
4188 }
4189
4190 #[test]
4191 fn confluence_attachment_serialize_includes_some_fields() {
4192 let attachment = ConfluenceAttachment {
4193 id: "att-1".to_string(),
4194 title: "x.txt".to_string(),
4195 media_type: Some("text/plain".to_string()),
4196 file_size: Some(42),
4197 download_url: Some("/dl".to_string()),
4198 version: Some(3),
4199 page_id: Some("12345".to_string()),
4200 file_id: Some("f-1".to_string()),
4201 };
4202 let json = serde_json::to_value(&attachment).unwrap();
4203 assert_eq!(json["media_type"], "text/plain");
4204 assert_eq!(json["file_size"], 42);
4205 assert_eq!(json["version"], 3);
4206 }
4207
4208 #[test]
4209 fn confluence_attachment_page_serialize_skips_none_cursor() {
4210 let page = ConfluenceAttachmentPage {
4211 results: vec![],
4212 next_cursor: None,
4213 };
4214 let json = serde_json::to_value(&page).unwrap();
4215 assert!(json.get("next_cursor").is_none());
4216 }
4217
4218 async fn mount_page_version(
4221 server: &wiremock::MockServer,
4222 page_id: &str,
4223 version: u32,
4224 adf_value: &str,
4225 ) {
4226 wiremock::Mock::given(wiremock::matchers::method("GET"))
4227 .and(wiremock::matchers::path(format!(
4228 "/wiki/api/v2/pages/{page_id}"
4229 )))
4230 .and(wiremock::matchers::query_param(
4231 "version",
4232 version.to_string(),
4233 ))
4234 .respond_with(
4235 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
4236 "id": page_id,
4237 "title": format!("Page {page_id} v{version}"),
4238 "status": "current",
4239 "spaceId": "98",
4240 "version": {"number": version},
4241 "body": {
4242 "atlas_doc_format": {"value": adf_value}
4243 }
4244 })),
4245 )
4246 .mount(server)
4247 .await;
4248
4249 wiremock::Mock::given(wiremock::matchers::method("GET"))
4250 .and(wiremock::matchers::path("/wiki/api/v2/spaces/98"))
4251 .respond_with(
4252 wiremock::ResponseTemplate::new(200)
4253 .set_body_json(serde_json::json!({"key": "ENG"})),
4254 )
4255 .mount(server)
4256 .await;
4257 }
4258
4259 #[tokio::test]
4260 async fn get_page_at_version_success() {
4261 use crate::atlassian::api::ContentMetadata;
4262
4263 let server = wiremock::MockServer::start().await;
4264 mount_page_version(
4265 &server,
4266 "12",
4267 3,
4268 r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"v3"}]}]}"#,
4269 )
4270 .await;
4271
4272 let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
4273 let api = ConfluenceApi::new(client);
4274 let item = api.get_page_at_version("12", 3).await.unwrap();
4275 assert_eq!(item.id, "12");
4276 assert_eq!(item.title, "Page 12 v3");
4277 assert!(item.body_adf.is_some());
4278 match item.metadata {
4279 ContentMetadata::Confluence {
4280 space_key, version, ..
4281 } => {
4282 assert_eq!(space_key, "ENG");
4283 assert_eq!(version, Some(3));
4284 }
4285 ContentMetadata::Jira { .. } => panic!("expected Confluence metadata"),
4286 }
4287 }
4288
4289 #[tokio::test]
4290 async fn get_page_at_version_404() {
4291 let server = wiremock::MockServer::start().await;
4292 wiremock::Mock::given(wiremock::matchers::method("GET"))
4293 .and(wiremock::matchers::path("/wiki/api/v2/pages/12"))
4294 .and(wiremock::matchers::query_param("version", "99"))
4295 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
4296 .mount(&server)
4297 .await;
4298
4299 let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
4300 let api = ConfluenceApi::new(client);
4301 let err = api.get_page_at_version("12", 99).await.unwrap_err();
4302 assert!(err.to_string().contains("404"));
4303 }
4304
4305 #[tokio::test]
4306 async fn get_page_at_version_no_body() {
4307 let server = wiremock::MockServer::start().await;
4308 wiremock::Mock::given(wiremock::matchers::method("GET"))
4309 .and(wiremock::matchers::path("/wiki/api/v2/pages/12"))
4310 .and(wiremock::matchers::query_param("version", "1"))
4311 .respond_with(
4312 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
4313 "id": "12",
4314 "title": "Empty",
4315 "status": "current",
4316 "spaceId": "1",
4317 "version": {"number": 1}
4318 })),
4319 )
4320 .mount(&server)
4321 .await;
4322 wiremock::Mock::given(wiremock::matchers::method("GET"))
4323 .and(wiremock::matchers::path("/wiki/api/v2/spaces/1"))
4324 .respond_with(
4325 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({"key": "S"})),
4326 )
4327 .mount(&server)
4328 .await;
4329
4330 let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
4331 let api = ConfluenceApi::new(client);
4332 let item = api.get_page_at_version("12", 1).await.unwrap();
4333 assert!(item.body_adf.is_none());
4334 }
4335
4336 fn version_at(number: u32, created: &str) -> PageVersion {
4339 PageVersion {
4340 number,
4341 created_at: created.to_string(),
4342 author_id: String::new(),
4343 message: String::new(),
4344 minor_edit: false,
4345 }
4346 }
4347
4348 fn fixture_versions() -> Vec<PageVersion> {
4349 vec![
4351 version_at(5, "2026-05-09T10:00:00Z"),
4352 version_at(4, "2026-05-08T10:00:00Z"),
4353 version_at(3, "2026-05-07T10:00:00Z"),
4354 version_at(2, "2026-05-06T10:00:00Z"),
4355 version_at(1, "2026-05-05T10:00:00Z"),
4356 ]
4357 }
4358
4359 #[test]
4360 fn resolve_version_latest() {
4361 let v = fixture_versions();
4362 assert_eq!(resolve_version("latest", &v, 5).unwrap(), 5);
4363 assert_eq!(resolve_version("LATEST", &v, 5).unwrap(), 5);
4364 }
4365
4366 #[test]
4367 fn resolve_version_previous_relative_to_anchor() {
4368 let v = fixture_versions();
4369 assert_eq!(resolve_version("previous", &v, 5).unwrap(), 4);
4371 assert_eq!(resolve_version("previous", &v, 3).unwrap(), 2);
4372 }
4373
4374 #[test]
4375 fn resolve_version_previous_at_first_version_errors() {
4376 let v = fixture_versions();
4377 let err = resolve_version("previous", &v, 1).unwrap_err();
4378 assert!(err.to_string().contains("out of range"));
4379 }
4380
4381 #[test]
4382 fn resolve_version_v_minus_offset() {
4383 let v = fixture_versions();
4384 assert_eq!(resolve_version("v-2", &v, 5).unwrap(), 3);
4385 assert_eq!(resolve_version("V-1", &v, 5).unwrap(), 4);
4386 }
4387
4388 #[test]
4389 fn resolve_version_v_minus_zero_rejected() {
4390 let v = fixture_versions();
4391 let err = resolve_version("v-0", &v, 5).unwrap_err();
4392 assert!(err.to_string().contains("> 0"));
4393 }
4394
4395 #[test]
4396 fn resolve_version_v_minus_too_deep() {
4397 let v = fixture_versions();
4398 let err = resolve_version("v-10", &v, 5).unwrap_err();
4399 assert!(err.to_string().contains("out of range"));
4400 }
4401
4402 #[test]
4403 fn resolve_version_numeric_in_range() {
4404 let v = fixture_versions();
4405 assert_eq!(resolve_version("3", &v, 5).unwrap(), 3);
4406 }
4407
4408 #[test]
4409 fn resolve_version_numeric_not_present() {
4410 let v = fixture_versions();
4411 let err = resolve_version("99", &v, 5).unwrap_err();
4412 assert!(err.to_string().contains("not found"));
4413 }
4414
4415 #[test]
4416 fn resolve_version_iso_picks_latest_at_or_before() {
4417 let v = fixture_versions();
4418 assert_eq!(resolve_version("2026-05-08T11:00:00Z", &v, 5).unwrap(), 4);
4421 assert_eq!(resolve_version("2026-05-09T10:00:00Z", &v, 5).unwrap(), 5);
4422 assert_eq!(resolve_version("2026-05-06", &v, 5).unwrap(), 1);
4427 }
4428
4429 #[test]
4430 fn resolve_version_iso_no_match_errors() {
4431 let v = fixture_versions();
4432 let err = resolve_version("2020-01-01", &v, 5).unwrap_err();
4433 assert!(err.to_string().contains("at or before"));
4434 }
4435
4436 #[test]
4437 fn resolve_version_empty_versions_errors() {
4438 let err = resolve_version("latest", &[], 0).unwrap_err();
4439 assert!(err.to_string().contains("no versions"));
4440 }
4441
4442 #[test]
4443 fn resolve_version_empty_string_rejected() {
4444 let v = fixture_versions();
4445 let err = resolve_version(" ", &v, 5).unwrap_err();
4446 assert!(err.to_string().contains("empty"));
4447 }
4448
4449 #[test]
4450 fn resolve_version_unparseable() {
4451 let v = fixture_versions();
4452 let err = resolve_version("garbage", &v, 5).unwrap_err();
4453 assert!(err.to_string().contains("Could not parse"));
4454 }
4455
4456 #[test]
4457 fn resolve_version_v_minus_with_non_numeric_suffix_rejected() {
4458 let v = fixture_versions();
4461 let err = resolve_version("v-abc", &v, 5).unwrap_err();
4462 assert!(
4463 err.to_string().contains("Invalid relative version offset"),
4464 "got: {err}"
4465 );
4466 }
4467
4468 #[test]
4469 fn resolve_version_v_minus_resolves_to_missing_version_errors() {
4470 let versions = vec![
4474 version_at(5, "2026-05-09T10:00:00Z"),
4475 version_at(4, "2026-05-08T10:00:00Z"),
4476 ];
4478 let err = resolve_version("v-2", &versions, 4).unwrap_err();
4479 assert!(err.to_string().contains("not found"), "got: {err}");
4480 }
4481
4482 #[tokio::test]
4483 async fn get_page_at_version_with_body_but_no_atlas_doc_format() {
4484 let server = wiremock::MockServer::start().await;
4487 wiremock::Mock::given(wiremock::matchers::method("GET"))
4488 .and(wiremock::matchers::path("/wiki/api/v2/pages/12"))
4489 .and(wiremock::matchers::query_param("version", "1"))
4490 .respond_with(
4491 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
4492 "id": "12",
4493 "title": "T",
4494 "status": "current",
4495 "spaceId": "1",
4496 "version": {"number": 1},
4497 "body": { }
4498 })),
4499 )
4500 .mount(&server)
4501 .await;
4502 wiremock::Mock::given(wiremock::matchers::method("GET"))
4503 .and(wiremock::matchers::path("/wiki/api/v2/spaces/1"))
4504 .respond_with(
4505 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({"key": "S"})),
4506 )
4507 .mount(&server)
4508 .await;
4509
4510 let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
4511 let api = ConfluenceApi::new(client);
4512 let item = api.get_page_at_version("12", 1).await.unwrap();
4513 assert!(item.body_adf.is_none());
4514 }
4515}