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