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