1use std::future::Future;
8use std::pin::Pin;
9
10use anyhow::{Context, Result};
11use serde::{Deserialize, Serialize};
12use tracing::debug;
13
14use crate::atlassian::adf::AdfDocument;
15use crate::atlassian::api::{AtlassianApi, ContentItem, ContentMetadata};
16use crate::atlassian::client::AtlassianClient;
17use crate::atlassian::error::AtlassianError;
18
19pub struct ConfluenceApi {
21 client: AtlassianClient,
22}
23
24impl ConfluenceApi {
25 pub fn new(client: AtlassianClient) -> Self {
27 Self { client }
28 }
29}
30
31#[derive(Deserialize)]
34struct ConfluencePageResponse {
35 id: String,
36 title: String,
37 status: String,
38 #[serde(rename = "spaceId")]
39 space_id: String,
40 version: Option<ConfluenceVersion>,
41 body: Option<ConfluenceBody>,
42 #[serde(rename = "parentId")]
43 parent_id: Option<String>,
44}
45
46#[derive(Deserialize)]
47struct ConfluenceVersion {
48 number: u32,
49}
50
51#[derive(Deserialize)]
52struct ConfluenceBody {
53 atlas_doc_format: Option<ConfluenceAtlasDoc>,
54}
55
56#[derive(Deserialize)]
57struct ConfluenceAtlasDoc {
58 value: String,
59}
60
61#[derive(Deserialize)]
64struct ConfluenceSpaceResponse {
65 key: String,
66}
67
68#[derive(Deserialize)]
69struct ConfluenceSpacesSearchResponse {
70 results: Vec<ConfluenceSpaceSearchEntry>,
71}
72
73#[derive(Deserialize)]
74struct ConfluenceSpaceSearchEntry {
75 id: String,
76}
77
78#[derive(Deserialize)]
81struct ConfluenceChildrenResponse {
82 results: Vec<ConfluenceChildEntry>,
83 #[serde(rename = "_links", default)]
84 links: Option<ConfluenceChildrenLinks>,
85}
86
87#[derive(Deserialize)]
88struct ConfluenceChildEntry {
89 id: String,
90 title: String,
91 #[serde(default)]
92 status: Option<String>,
93}
94
95#[derive(Deserialize)]
96struct ConfluenceChildrenLinks {
97 next: Option<String>,
98}
99
100#[derive(Deserialize)]
102struct ConfluenceSpacePagesResponse {
103 results: Vec<ConfluenceSpacePageEntry>,
104 #[serde(rename = "_links", default)]
105 links: Option<ConfluenceChildrenLinks>,
106}
107
108#[derive(Deserialize)]
109struct ConfluenceSpacePageEntry {
110 id: String,
111 title: String,
112 #[serde(default)]
113 status: Option<String>,
114 #[serde(rename = "parentId", default)]
115 parent_id: Option<String>,
116}
117
118#[derive(Debug, Clone, serde::Serialize)]
120pub struct ChildPage {
121 pub id: String,
123 pub title: String,
125 #[serde(default, skip_serializing_if = "String::is_empty")]
127 pub status: String,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub parent_id: Option<String>,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub space_key: Option<String>,
134}
135
136#[derive(Debug, Clone, Serialize)]
140pub struct ConfluenceComment {
141 pub id: String,
143 pub author: String,
145 pub body_adf: Option<serde_json::Value>,
147 pub created: String,
149}
150
151#[derive(Deserialize)]
152struct ConfluenceCommentsResponse {
153 results: Vec<ConfluenceCommentEntry>,
154 #[serde(rename = "_links", default)]
155 links: Option<ConfluenceCommentsLinks>,
156}
157
158#[derive(Deserialize)]
159struct ConfluenceCommentsLinks {
160 next: Option<String>,
161}
162
163#[derive(Deserialize)]
164struct ConfluenceCommentEntry {
165 id: String,
166 #[serde(default)]
167 version: Option<ConfluenceCommentVersion>,
168 #[serde(default)]
169 body: Option<ConfluenceCommentBody>,
170}
171
172#[derive(Deserialize)]
173struct ConfluenceCommentVersion {
174 #[serde(rename = "authorId", default)]
175 author_id: Option<String>,
176 #[serde(rename = "createdAt", default)]
177 created_at: Option<String>,
178}
179
180#[derive(Deserialize)]
181struct ConfluenceCommentBody {
182 atlas_doc_format: Option<ConfluenceAtlasDoc>,
183}
184
185#[derive(Serialize)]
186struct ConfluenceAddCommentRequest {
187 #[serde(rename = "pageId")]
188 page_id: String,
189 body: ConfluenceUpdateBody,
190}
191
192#[derive(Deserialize)]
195struct ConfluenceLabelsResponse {
196 results: Vec<ConfluenceLabelEntry>,
197 #[serde(rename = "_links", default)]
198 links: Option<ConfluenceLabelsLinks>,
199}
200
201#[derive(Deserialize)]
202struct ConfluenceLabelEntry {
203 id: String,
204 name: String,
205 prefix: String,
206}
207
208#[derive(Deserialize)]
209struct ConfluenceLabelsLinks {
210 next: Option<String>,
211}
212
213#[derive(Debug, Clone, Serialize)]
215pub struct ConfluenceLabel {
216 pub id: String,
218 pub name: String,
220 pub prefix: String,
222}
223
224#[derive(Serialize)]
225struct ConfluenceAddLabelEntry {
226 prefix: String,
227 name: String,
228}
229
230#[derive(Serialize)]
233struct ConfluenceCreateRequest {
234 #[serde(rename = "spaceId")]
235 space_id: String,
236 title: String,
237 body: ConfluenceUpdateBody,
238 #[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
239 parent_id: Option<String>,
240 status: String,
241}
242
243#[derive(Deserialize)]
244struct ConfluenceCreateResponse {
245 id: String,
246}
247
248#[derive(Serialize)]
251struct ConfluenceUpdateRequest {
252 id: String,
253 status: String,
254 title: String,
255 body: ConfluenceUpdateBody,
256 version: ConfluenceUpdateVersion,
257}
258
259#[derive(Serialize)]
260struct ConfluenceUpdateBody {
261 representation: String,
262 value: String,
263}
264
265#[derive(Serialize)]
266struct ConfluenceUpdateVersion {
267 number: u32,
268 message: Option<String>,
269}
270
271impl AtlassianApi for ConfluenceApi {
272 fn get_content<'a>(
273 &'a self,
274 id: &'a str,
275 ) -> Pin<Box<dyn Future<Output = Result<ContentItem>> + Send + 'a>> {
276 Box::pin(async move {
277 let url = format!(
278 "{}/wiki/api/v2/pages/{}?body-format=atlas_doc_format",
279 self.client.instance_url(),
280 id
281 );
282
283 let response = self
284 .client
285 .get_json(&url)
286 .await
287 .context("Failed to fetch Confluence page")?;
288
289 if !response.status().is_success() {
290 let status = response.status().as_u16();
291 let body = response.text().await.unwrap_or_default();
292 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
293 }
294
295 let page: ConfluencePageResponse = response
296 .json()
297 .await
298 .context("Failed to parse Confluence page response")?;
299
300 debug!(
301 page_id = page.id,
302 title = page.title,
303 "Fetched Confluence page"
304 );
305
306 let body_adf = if let Some(body) = &page.body {
308 if let Some(atlas_doc) = &body.atlas_doc_format {
309 if tracing::enabled!(tracing::Level::TRACE) {
310 if let Ok(pretty) =
311 serde_json::from_str::<serde_json::Value>(&atlas_doc.value)
312 .and_then(|v| serde_json::to_string_pretty(&v))
313 {
314 tracing::trace!("Original ADF from Confluence:\n{pretty}");
315 }
316 }
317 Some(
318 serde_json::from_str(&atlas_doc.value)
319 .context("Failed to parse ADF from Confluence body")?,
320 )
321 } else {
322 None
323 }
324 } else {
325 None
326 };
327
328 let space_key = self.resolve_space_key(&page.space_id).await?;
330
331 Ok(ContentItem {
332 id: page.id,
333 title: page.title,
334 body_adf,
335 metadata: ContentMetadata::Confluence {
336 space_key,
337 status: Some(page.status),
338 version: page.version.map(|v| v.number),
339 parent_id: page.parent_id,
340 },
341 })
342 })
343 }
344
345 fn update_content<'a>(
346 &'a self,
347 id: &'a str,
348 body_adf: &'a AdfDocument,
349 title: Option<&'a str>,
350 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>> {
351 Box::pin(async move {
352 let current = self.get_content(id).await?;
354 let current_version = match ¤t.metadata {
355 ContentMetadata::Confluence { version, .. } => version.unwrap_or(1),
356 ContentMetadata::Jira { .. } => 1,
357 };
358 let current_title = current.title;
359
360 let adf_json =
361 serde_json::to_string(body_adf).context("Failed to serialize ADF document")?;
362
363 debug!(
364 page_id = id,
365 version = current_version + 1,
366 adf_bytes = adf_json.len(),
367 "Updating Confluence page"
368 );
369 if tracing::enabled!(tracing::Level::TRACE) {
370 let pretty = serde_json::to_string_pretty(body_adf)
371 .unwrap_or_else(|e| format!("<serialization error: {e}>"));
372 tracing::trace!("ADF body for update:\n{pretty}");
373 }
374
375 let update = ConfluenceUpdateRequest {
376 id: id.to_string(),
377 status: "current".to_string(),
378 title: title.unwrap_or(¤t_title).to_string(),
379 body: ConfluenceUpdateBody {
380 representation: "atlas_doc_format".to_string(),
381 value: adf_json,
382 },
383 version: ConfluenceUpdateVersion {
384 number: current_version + 1,
385 message: None,
386 },
387 };
388
389 let url = format!("{}/wiki/api/v2/pages/{}", self.client.instance_url(), id);
390
391 let response = self
392 .client
393 .put_json(&url, &update)
394 .await
395 .context("Failed to update Confluence page")?;
396
397 if !response.status().is_success() {
398 let status = response.status().as_u16();
399 let body = response.text().await.unwrap_or_default();
400 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
401 }
402
403 Ok(())
404 })
405 }
406
407 fn verify_auth<'a>(&'a self) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>> {
408 Box::pin(async move {
410 let user = self.client.get_myself().await?;
411 Ok(user.display_name)
412 })
413 }
414
415 fn backend_name(&self) -> &'static str {
416 "confluence"
417 }
418}
419
420impl ConfluenceApi {
421 pub async fn resolve_space_id(&self, space_key: &str) -> Result<String> {
423 let url = format!(
424 "{}/wiki/api/v2/spaces?keys={}",
425 self.client.instance_url(),
426 space_key
427 );
428
429 let response = self
430 .client
431 .get_json(&url)
432 .await
433 .context("Failed to search Confluence spaces")?;
434
435 if !response.status().is_success() {
436 let status = response.status().as_u16();
437 let body = response.text().await.unwrap_or_default();
438 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
439 }
440
441 let resp: ConfluenceSpacesSearchResponse = response
442 .json()
443 .await
444 .context("Failed to parse Confluence spaces response")?;
445
446 resp.results
447 .first()
448 .map(|s| s.id.clone())
449 .ok_or_else(|| anyhow::anyhow!("Space with key \"{space_key}\" not found"))
450 }
451
452 pub async fn create_page(
454 &self,
455 space_key: &str,
456 title: &str,
457 body_adf: &AdfDocument,
458 parent_id: Option<&str>,
459 ) -> Result<String> {
460 let space_id = self.resolve_space_id(space_key).await?;
461
462 let adf_json =
463 serde_json::to_string(body_adf).context("Failed to serialize ADF document")?;
464
465 let request = ConfluenceCreateRequest {
466 space_id,
467 title: title.to_string(),
468 body: ConfluenceUpdateBody {
469 representation: "atlas_doc_format".to_string(),
470 value: adf_json,
471 },
472 parent_id: parent_id.map(String::from),
473 status: "current".to_string(),
474 };
475
476 let url = format!("{}/wiki/api/v2/pages", self.client.instance_url());
477
478 let response = self
479 .client
480 .post_json(&url, &request)
481 .await
482 .context("Failed to create Confluence page")?;
483
484 if !response.status().is_success() {
485 let status = response.status().as_u16();
486 let body = response.text().await.unwrap_or_default();
487 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
488 }
489
490 let resp: ConfluenceCreateResponse = response
491 .json()
492 .await
493 .context("Failed to parse Confluence create response")?;
494
495 Ok(resp.id)
496 }
497
498 pub async fn delete_page(&self, id: &str, purge: bool) -> Result<()> {
500 let mut url = format!("{}/wiki/api/v2/pages/{}", self.client.instance_url(), id);
501 if purge {
502 url.push_str("?purge=true");
503 }
504
505 let response = self.client.delete(&url).await?;
506
507 if !response.status().is_success() {
508 let status = response.status().as_u16();
509 let body = response.text().await.unwrap_or_default();
510 if status == 404 {
511 anyhow::bail!(
512 "Page {id} not found or insufficient permissions. \
513 Confluence returns 404 when the API user lacks space-level delete permission. \
514 Check Space Settings > Permissions."
515 );
516 }
517 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
518 }
519
520 Ok(())
521 }
522
523 pub async fn get_children(&self, page_id: &str) -> Result<Vec<ChildPage>> {
528 let mut all_children = Vec::new();
529 let mut url = format!(
530 "{}/wiki/rest/api/content/{}/child/page?limit=50",
531 self.client.instance_url(),
532 page_id
533 );
534
535 loop {
536 let response = self
537 .client
538 .get_json(&url)
539 .await
540 .context("Failed to fetch child pages")?;
541
542 if !response.status().is_success() {
543 let status = response.status().as_u16();
544 let body = response.text().await.unwrap_or_default();
545 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
546 }
547
548 let resp: ConfluenceChildrenResponse = response
549 .json()
550 .await
551 .context("Failed to parse children response")?;
552
553 let page_count = resp.results.len();
554 for child in resp.results {
555 all_children.push(ChildPage {
556 id: child.id,
557 title: child.title,
558 status: child.status.unwrap_or_default(),
559 parent_id: Some(page_id.to_string()),
560 space_key: None,
561 });
562 }
563
564 match resp.links.and_then(|l| l.next) {
565 Some(next_path) if page_count > 0 => {
566 url = format!("{}{}", self.client.instance_url(), next_path);
567 }
568 _ => break,
569 }
570 }
571
572 Ok(all_children)
573 }
574
575 pub async fn get_space_root_pages(&self, space_id: &str) -> Result<Vec<ChildPage>> {
579 let mut all_pages = Vec::new();
580 let mut url = format!(
581 "{}/wiki/api/v2/spaces/{}/pages?depth=root&limit=50",
582 self.client.instance_url(),
583 space_id
584 );
585
586 loop {
587 let response = self
588 .client
589 .get_json(&url)
590 .await
591 .context("Failed to fetch space root pages")?;
592
593 if !response.status().is_success() {
594 let status = response.status().as_u16();
595 let body = response.text().await.unwrap_or_default();
596 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
597 }
598
599 let resp: ConfluenceSpacePagesResponse = response
600 .json()
601 .await
602 .context("Failed to parse space pages response")?;
603
604 let page_count = resp.results.len();
605 for entry in resp.results {
606 all_pages.push(ChildPage {
607 id: entry.id,
608 title: entry.title,
609 status: entry.status.unwrap_or_default(),
610 parent_id: entry.parent_id,
611 space_key: None,
612 });
613 }
614
615 match resp.links.and_then(|l| l.next) {
616 Some(next_path) if page_count > 0 => {
617 url = format!("{}{}", self.client.instance_url(), next_path);
618 }
619 _ => break,
620 }
621 }
622
623 Ok(all_pages)
624 }
625
626 pub async fn get_page_comments(&self, page_id: &str) -> Result<Vec<ConfluenceComment>> {
628 let mut all_comments = Vec::new();
629 let mut url = format!(
630 "{}/wiki/api/v2/pages/{}/footer-comments?body-format=atlas_doc_format",
631 self.client.instance_url(),
632 page_id
633 );
634
635 loop {
636 let response = self
637 .client
638 .get_json(&url)
639 .await
640 .context("Failed to fetch Confluence page comments")?;
641
642 if !response.status().is_success() {
643 let status = response.status().as_u16();
644 let body = response.text().await.unwrap_or_default();
645 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
646 }
647
648 let resp: ConfluenceCommentsResponse = response
649 .json()
650 .await
651 .context("Failed to parse Confluence comments response")?;
652
653 let page_count = resp.results.len();
654 for c in resp.results {
655 let body_adf = c.body.and_then(|b| {
656 b.atlas_doc_format
657 .and_then(|a| serde_json::from_str(&a.value).ok())
658 });
659 let author = c
660 .version
661 .as_ref()
662 .and_then(|v| v.author_id.clone())
663 .unwrap_or_default();
664 let created = c.version.and_then(|v| v.created_at).unwrap_or_default();
665 all_comments.push(ConfluenceComment {
666 id: c.id,
667 author,
668 body_adf,
669 created,
670 });
671 }
672
673 match resp.links.and_then(|l| l.next) {
674 Some(next_path) if page_count > 0 => {
675 url = format!("{}{}", self.client.instance_url(), next_path);
676 }
677 _ => break,
678 }
679 }
680
681 Ok(all_comments)
682 }
683
684 pub async fn add_page_comment(&self, page_id: &str, body_adf: &AdfDocument) -> Result<()> {
686 let adf_json =
687 serde_json::to_string(body_adf).context("Failed to serialize ADF document")?;
688
689 let request = ConfluenceAddCommentRequest {
690 page_id: page_id.to_string(),
691 body: ConfluenceUpdateBody {
692 representation: "atlas_doc_format".to_string(),
693 value: adf_json,
694 },
695 };
696
697 let url = format!("{}/wiki/api/v2/footer-comments", self.client.instance_url());
698
699 let response = self
700 .client
701 .post_json(&url, &request)
702 .await
703 .context("Failed to add Confluence page comment")?;
704
705 if !response.status().is_success() {
706 let status = response.status().as_u16();
707 let body = response.text().await.unwrap_or_default();
708 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
709 }
710
711 Ok(())
712 }
713
714 async fn resolve_space_key(&self, space_id: &str) -> Result<String> {
716 let url = format!(
717 "{}/wiki/api/v2/spaces/{}",
718 self.client.instance_url(),
719 space_id
720 );
721
722 let response = self
723 .client
724 .get_json(&url)
725 .await
726 .context("Failed to fetch Confluence space")?;
727
728 if !response.status().is_success() {
729 return Ok(space_id.to_string());
731 }
732
733 let space: ConfluenceSpaceResponse = response
734 .json()
735 .await
736 .context("Failed to parse Confluence space response")?;
737
738 Ok(space.key)
739 }
740
741 pub async fn get_labels(&self, page_id: &str) -> Result<Vec<ConfluenceLabel>> {
743 let mut all_labels = Vec::new();
744 let mut url = format!(
745 "{}/wiki/api/v2/pages/{}/labels",
746 self.client.instance_url(),
747 page_id
748 );
749
750 loop {
751 let response = self
752 .client
753 .get_json(&url)
754 .await
755 .context("Failed to fetch page labels")?;
756
757 if !response.status().is_success() {
758 let status = response.status().as_u16();
759 let body = response.text().await.unwrap_or_default();
760 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
761 }
762
763 let resp: ConfluenceLabelsResponse = response
764 .json()
765 .await
766 .context("Failed to parse labels response")?;
767
768 let page_count = resp.results.len();
769 for entry in resp.results {
770 all_labels.push(ConfluenceLabel {
771 id: entry.id,
772 name: entry.name,
773 prefix: entry.prefix,
774 });
775 }
776
777 match resp.links.and_then(|l| l.next) {
778 Some(next_path) if page_count > 0 => {
779 url = format!("{}{}", self.client.instance_url(), next_path);
780 }
781 _ => break,
782 }
783 }
784
785 Ok(all_labels)
786 }
787
788 pub async fn add_labels(&self, page_id: &str, labels: &[String]) -> Result<()> {
790 let url = format!(
791 "{}/wiki/rest/api/content/{}/label",
792 self.client.instance_url(),
793 page_id
794 );
795
796 let body: Vec<ConfluenceAddLabelEntry> = labels
797 .iter()
798 .map(|name| ConfluenceAddLabelEntry {
799 prefix: "global".to_string(),
800 name: name.clone(),
801 })
802 .collect();
803
804 let response = self
805 .client
806 .post_json(&url, &body)
807 .await
808 .context("Failed to add labels")?;
809
810 if !response.status().is_success() {
811 let status = response.status().as_u16();
812 let body = response.text().await.unwrap_or_default();
813 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
814 }
815
816 Ok(())
817 }
818
819 pub async fn remove_label(&self, page_id: &str, label_name: &str) -> Result<()> {
821 let url = format!(
822 "{}/wiki/rest/api/content/{}/label/{}",
823 self.client.instance_url(),
824 page_id,
825 label_name
826 );
827
828 let response = self.client.delete(&url).await?;
829
830 if !response.status().is_success() {
831 let status = response.status().as_u16();
832 let body = response.text().await.unwrap_or_default();
833 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
834 }
835
836 Ok(())
837 }
838}
839
840#[cfg(test)]
841#[allow(clippy::unwrap_used, clippy::expect_used)]
842mod tests {
843 use super::*;
844
845 #[test]
846 fn confluence_api_backend_name() {
847 let client =
848 AtlassianClient::new("https://org.atlassian.net", "user@test.com", "token").unwrap();
849 let api = ConfluenceApi::new(client);
850 assert_eq!(api.backend_name(), "confluence");
851 }
852
853 #[test]
854 fn confluence_page_response_deserialization() {
855 let json = r#"{
856 "id": "12345",
857 "title": "Test Page",
858 "status": "current",
859 "spaceId": "98765",
860 "version": {"number": 3},
861 "body": {
862 "atlas_doc_format": {
863 "value": "{\"version\":1,\"type\":\"doc\",\"content\":[]}"
864 }
865 },
866 "parentId": "11111"
867 }"#;
868 let page: ConfluencePageResponse = serde_json::from_str(json).unwrap();
869 assert_eq!(page.id, "12345");
870 assert_eq!(page.title, "Test Page");
871 assert_eq!(page.status, "current");
872 assert_eq!(page.space_id, "98765");
873 assert_eq!(page.version.unwrap().number, 3);
874 assert_eq!(page.parent_id.as_deref(), Some("11111"));
875
876 let body = page.body.unwrap();
877 let atlas_doc = body.atlas_doc_format.unwrap();
878 let adf: serde_json::Value = serde_json::from_str(&atlas_doc.value).unwrap();
879 assert_eq!(adf["version"], 1);
880 assert_eq!(adf["type"], "doc");
881 }
882
883 #[test]
884 fn confluence_page_response_minimal() {
885 let json = r#"{
886 "id": "99",
887 "title": "Minimal",
888 "status": "draft",
889 "spaceId": "1"
890 }"#;
891 let page: ConfluencePageResponse = serde_json::from_str(json).unwrap();
892 assert_eq!(page.id, "99");
893 assert!(page.version.is_none());
894 assert!(page.body.is_none());
895 assert!(page.parent_id.is_none());
896 }
897
898 #[test]
899 fn confluence_update_request_serialization() {
900 let req = ConfluenceUpdateRequest {
901 id: "12345".to_string(),
902 status: "current".to_string(),
903 title: "Updated Title".to_string(),
904 body: ConfluenceUpdateBody {
905 representation: "atlas_doc_format".to_string(),
906 value: r#"{"version":1,"type":"doc","content":[]}"#.to_string(),
907 },
908 version: ConfluenceUpdateVersion {
909 number: 4,
910 message: None,
911 },
912 };
913
914 let json = serde_json::to_value(&req).unwrap();
915 assert_eq!(json["id"], "12345");
916 assert_eq!(json["status"], "current");
917 assert_eq!(json["title"], "Updated Title");
918 assert_eq!(json["body"]["representation"], "atlas_doc_format");
919 assert_eq!(json["version"]["number"], 4);
920 }
921
922 #[test]
923 fn confluence_update_version_with_message() {
924 let req = ConfluenceUpdateRequest {
925 id: "1".to_string(),
926 status: "current".to_string(),
927 title: "T".to_string(),
928 body: ConfluenceUpdateBody {
929 representation: "atlas_doc_format".to_string(),
930 value: "{}".to_string(),
931 },
932 version: ConfluenceUpdateVersion {
933 number: 2,
934 message: Some("Updated via API".to_string()),
935 },
936 };
937 let json = serde_json::to_value(&req).unwrap();
938 assert_eq!(json["version"]["message"], "Updated via API");
939 }
940
941 #[test]
942 fn confluence_space_response_deserialization() {
943 let json = r#"{"key": "ENG"}"#;
944 let space: ConfluenceSpaceResponse = serde_json::from_str(json).unwrap();
945 assert_eq!(space.key, "ENG");
946 }
947
948 async fn setup_confluence_mock() -> (wiremock::MockServer, ConfluenceApi) {
950 let server = wiremock::MockServer::start().await;
951
952 let page_json = serde_json::json!({
953 "id": "12345",
954 "title": "Test Page",
955 "status": "current",
956 "spaceId": "98765",
957 "version": {"number": 3},
958 "body": {
959 "atlas_doc_format": {
960 "value": "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"Hello\"}]}]}"
961 }
962 },
963 "parentId": "11111"
964 });
965
966 wiremock::Mock::given(wiremock::matchers::method("GET"))
967 .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
968 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&page_json))
969 .mount(&server)
970 .await;
971
972 wiremock::Mock::given(wiremock::matchers::method("GET"))
973 .and(wiremock::matchers::path("/wiki/api/v2/spaces/98765"))
974 .respond_with(
975 wiremock::ResponseTemplate::new(200)
976 .set_body_json(serde_json::json!({"key": "ENG"})),
977 )
978 .mount(&server)
979 .await;
980
981 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
982 let api = ConfluenceApi::new(client);
983
984 (server, api)
985 }
986
987 #[tokio::test]
988 async fn get_content_success() {
989 use crate::atlassian::api::{AtlassianApi, ContentMetadata};
990
991 let (_server, api) = setup_confluence_mock().await;
992 let item = api.get_content("12345").await.unwrap();
993
994 assert_eq!(item.id, "12345");
995 assert_eq!(item.title, "Test Page");
996 assert!(item.body_adf.is_some());
997 match &item.metadata {
998 ContentMetadata::Confluence {
999 space_key,
1000 status,
1001 version,
1002 parent_id,
1003 } => {
1004 assert_eq!(space_key, "ENG");
1005 assert_eq!(status.as_deref(), Some("current"));
1006 assert_eq!(*version, Some(3));
1007 assert_eq!(parent_id.as_deref(), Some("11111"));
1008 }
1009 ContentMetadata::Jira { .. } => panic!("Expected Confluence metadata"),
1010 }
1011 }
1012
1013 #[tokio::test]
1014 async fn get_content_api_error() {
1015 use crate::atlassian::api::AtlassianApi;
1016
1017 let server = wiremock::MockServer::start().await;
1018
1019 wiremock::Mock::given(wiremock::matchers::method("GET"))
1020 .and(wiremock::matchers::path("/wiki/api/v2/pages/99999"))
1021 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
1022 .mount(&server)
1023 .await;
1024
1025 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1026 let api = ConfluenceApi::new(client);
1027 let err = api.get_content("99999").await.unwrap_err();
1028 assert!(err.to_string().contains("404"));
1029 }
1030
1031 #[tokio::test]
1032 async fn get_content_no_body() {
1033 use crate::atlassian::api::AtlassianApi;
1034
1035 let server = wiremock::MockServer::start().await;
1036
1037 wiremock::Mock::given(wiremock::matchers::method("GET"))
1038 .and(wiremock::matchers::path("/wiki/api/v2/pages/55555"))
1039 .respond_with(
1040 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1041 "id": "55555",
1042 "title": "No Body",
1043 "status": "draft",
1044 "spaceId": "11111"
1045 })),
1046 )
1047 .mount(&server)
1048 .await;
1049
1050 wiremock::Mock::given(wiremock::matchers::method("GET"))
1051 .and(wiremock::matchers::path("/wiki/api/v2/spaces/11111"))
1052 .respond_with(
1053 wiremock::ResponseTemplate::new(200)
1054 .set_body_json(serde_json::json!({"key": "DEV"})),
1055 )
1056 .mount(&server)
1057 .await;
1058
1059 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1060 let api = ConfluenceApi::new(client);
1061 let item = api.get_content("55555").await.unwrap();
1062 assert!(item.body_adf.is_none());
1063 }
1064
1065 #[tokio::test]
1066 async fn update_content_success() {
1067 use crate::atlassian::api::AtlassianApi;
1068
1069 let (server, api) = setup_confluence_mock().await;
1070
1071 wiremock::Mock::given(wiremock::matchers::method("PUT"))
1072 .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
1073 .respond_with(wiremock::ResponseTemplate::new(200))
1074 .mount(&server)
1075 .await;
1076
1077 let adf = AdfDocument::new();
1078 let result = api.update_content("12345", &adf, Some("New Title")).await;
1079 assert!(result.is_ok());
1080 }
1081
1082 #[tokio::test]
1083 async fn update_content_api_error() {
1084 use crate::atlassian::api::AtlassianApi;
1085
1086 let (server, api) = setup_confluence_mock().await;
1087
1088 wiremock::Mock::given(wiremock::matchers::method("PUT"))
1089 .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
1090 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
1091 .mount(&server)
1092 .await;
1093
1094 let adf = AdfDocument::new();
1095 let err = api.update_content("12345", &adf, None).await.unwrap_err();
1096 assert!(err.to_string().contains("403"));
1097 }
1098
1099 #[tokio::test]
1100 async fn verify_auth_success() {
1101 use crate::atlassian::api::AtlassianApi;
1102
1103 let server = wiremock::MockServer::start().await;
1104
1105 wiremock::Mock::given(wiremock::matchers::method("GET"))
1106 .and(wiremock::matchers::path("/rest/api/3/myself"))
1107 .respond_with(
1108 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1109 "displayName": "Alice",
1110 "accountId": "abc123"
1111 })),
1112 )
1113 .mount(&server)
1114 .await;
1115
1116 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1117 let api = ConfluenceApi::new(client);
1118 let name = api.verify_auth().await.unwrap();
1119 assert_eq!(name, "Alice");
1120 }
1121
1122 #[tokio::test]
1123 async fn resolve_space_id_success() {
1124 let server = wiremock::MockServer::start().await;
1125
1126 wiremock::Mock::given(wiremock::matchers::method("GET"))
1127 .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
1128 .respond_with(
1129 wiremock::ResponseTemplate::new(200)
1130 .set_body_json(serde_json::json!({"results": [{"id": "98765"}]})),
1131 )
1132 .mount(&server)
1133 .await;
1134
1135 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1136 let api = ConfluenceApi::new(client);
1137 let id = api.resolve_space_id("ENG").await.unwrap();
1138 assert_eq!(id, "98765");
1139 }
1140
1141 #[tokio::test]
1142 async fn resolve_space_id_not_found() {
1143 let server = wiremock::MockServer::start().await;
1144
1145 wiremock::Mock::given(wiremock::matchers::method("GET"))
1146 .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
1147 .respond_with(
1148 wiremock::ResponseTemplate::new(200)
1149 .set_body_json(serde_json::json!({"results": []})),
1150 )
1151 .mount(&server)
1152 .await;
1153
1154 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1155 let api = ConfluenceApi::new(client);
1156 let err = api.resolve_space_id("NOPE").await.unwrap_err();
1157 assert!(err.to_string().contains("not found"));
1158 }
1159
1160 #[tokio::test]
1161 async fn resolve_space_id_api_error() {
1162 let server = wiremock::MockServer::start().await;
1163
1164 wiremock::Mock::given(wiremock::matchers::method("GET"))
1165 .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
1166 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
1167 .mount(&server)
1168 .await;
1169
1170 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1171 let api = ConfluenceApi::new(client);
1172 let err = api.resolve_space_id("ENG").await.unwrap_err();
1173 assert!(err.to_string().contains("403"));
1174 }
1175
1176 #[tokio::test]
1177 async fn create_page_success() {
1178 let server = wiremock::MockServer::start().await;
1179
1180 wiremock::Mock::given(wiremock::matchers::method("GET"))
1182 .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
1183 .respond_with(
1184 wiremock::ResponseTemplate::new(200)
1185 .set_body_json(serde_json::json!({"results": [{"id": "98765"}]})),
1186 )
1187 .mount(&server)
1188 .await;
1189
1190 wiremock::Mock::given(wiremock::matchers::method("POST"))
1192 .and(wiremock::matchers::path("/wiki/api/v2/pages"))
1193 .respond_with(
1194 wiremock::ResponseTemplate::new(200)
1195 .set_body_json(serde_json::json!({"id": "54321"})),
1196 )
1197 .mount(&server)
1198 .await;
1199
1200 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1201 let api = ConfluenceApi::new(client);
1202 let adf = AdfDocument::new();
1203 let id = api
1204 .create_page("ENG", "New Page", &adf, None)
1205 .await
1206 .unwrap();
1207 assert_eq!(id, "54321");
1208 }
1209
1210 #[tokio::test]
1211 async fn create_page_with_parent() {
1212 let server = wiremock::MockServer::start().await;
1213
1214 wiremock::Mock::given(wiremock::matchers::method("GET"))
1215 .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
1216 .respond_with(
1217 wiremock::ResponseTemplate::new(200)
1218 .set_body_json(serde_json::json!({"results": [{"id": "98765"}]})),
1219 )
1220 .mount(&server)
1221 .await;
1222
1223 wiremock::Mock::given(wiremock::matchers::method("POST"))
1224 .and(wiremock::matchers::path("/wiki/api/v2/pages"))
1225 .respond_with(
1226 wiremock::ResponseTemplate::new(200)
1227 .set_body_json(serde_json::json!({"id": "54322"})),
1228 )
1229 .mount(&server)
1230 .await;
1231
1232 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1233 let api = ConfluenceApi::new(client);
1234 let adf = AdfDocument::new();
1235 let id = api
1236 .create_page("ENG", "Child Page", &adf, Some("11111"))
1237 .await
1238 .unwrap();
1239 assert_eq!(id, "54322");
1240 }
1241
1242 #[tokio::test]
1243 async fn create_page_api_error() {
1244 let server = wiremock::MockServer::start().await;
1245
1246 wiremock::Mock::given(wiremock::matchers::method("GET"))
1247 .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
1248 .respond_with(
1249 wiremock::ResponseTemplate::new(200)
1250 .set_body_json(serde_json::json!({"results": [{"id": "98765"}]})),
1251 )
1252 .mount(&server)
1253 .await;
1254
1255 wiremock::Mock::given(wiremock::matchers::method("POST"))
1256 .and(wiremock::matchers::path("/wiki/api/v2/pages"))
1257 .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Bad Request"))
1258 .mount(&server)
1259 .await;
1260
1261 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1262 let api = ConfluenceApi::new(client);
1263 let adf = AdfDocument::new();
1264 let err = api
1265 .create_page("ENG", "Fail", &adf, None)
1266 .await
1267 .unwrap_err();
1268 assert!(err.to_string().contains("400"));
1269 }
1270
1271 #[tokio::test]
1272 async fn delete_page_success() {
1273 let server = wiremock::MockServer::start().await;
1274
1275 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
1276 .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
1277 .respond_with(wiremock::ResponseTemplate::new(204))
1278 .expect(1)
1279 .mount(&server)
1280 .await;
1281
1282 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1283 let api = ConfluenceApi::new(client);
1284 let result = api.delete_page("12345", false).await;
1285 assert!(result.is_ok());
1286 }
1287
1288 #[tokio::test]
1289 async fn delete_page_with_purge() {
1290 let server = wiremock::MockServer::start().await;
1291
1292 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
1293 .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
1294 .and(wiremock::matchers::query_param("purge", "true"))
1295 .respond_with(wiremock::ResponseTemplate::new(204))
1296 .expect(1)
1297 .mount(&server)
1298 .await;
1299
1300 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1301 let api = ConfluenceApi::new(client);
1302 let result = api.delete_page("12345", true).await;
1303 assert!(result.is_ok());
1304 }
1305
1306 #[tokio::test]
1307 async fn delete_page_not_found_hints_permissions() {
1308 let server = wiremock::MockServer::start().await;
1309
1310 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
1311 .and(wiremock::matchers::path("/wiki/api/v2/pages/99999"))
1312 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
1313 .expect(1)
1314 .mount(&server)
1315 .await;
1316
1317 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1318 let api = ConfluenceApi::new(client);
1319 let err = api.delete_page("99999", false).await.unwrap_err();
1320 let msg = err.to_string();
1321 assert!(msg.contains("not found or insufficient permissions"));
1322 assert!(msg.contains("Space Settings"));
1323 }
1324
1325 #[tokio::test]
1326 async fn delete_page_forbidden() {
1327 let server = wiremock::MockServer::start().await;
1328
1329 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
1330 .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
1331 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
1332 .expect(1)
1333 .mount(&server)
1334 .await;
1335
1336 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1337 let api = ConfluenceApi::new(client);
1338 let err = api.delete_page("12345", false).await.unwrap_err();
1339 assert!(err.to_string().contains("403"));
1340 }
1341
1342 #[tokio::test]
1343 async fn get_children_success() {
1344 let server = wiremock::MockServer::start().await;
1345
1346 wiremock::Mock::given(wiremock::matchers::method("GET"))
1347 .and(wiremock::matchers::path(
1348 "/wiki/rest/api/content/12345/child/page",
1349 ))
1350 .respond_with(
1351 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1352 "results": [
1353 {"id": "111", "title": "Child One"},
1354 {"id": "222", "title": "Child Two"}
1355 ]
1356 })),
1357 )
1358 .expect(1)
1359 .mount(&server)
1360 .await;
1361
1362 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1363 let api = ConfluenceApi::new(client);
1364 let children = api.get_children("12345").await.unwrap();
1365
1366 assert_eq!(children.len(), 2);
1367 assert_eq!(children[0].id, "111");
1368 assert_eq!(children[0].title, "Child One");
1369 assert_eq!(children[1].id, "222");
1370 }
1371
1372 #[tokio::test]
1373 async fn get_children_empty() {
1374 let server = wiremock::MockServer::start().await;
1375
1376 wiremock::Mock::given(wiremock::matchers::method("GET"))
1377 .and(wiremock::matchers::path(
1378 "/wiki/rest/api/content/12345/child/page",
1379 ))
1380 .respond_with(
1381 wiremock::ResponseTemplate::new(200)
1382 .set_body_json(serde_json::json!({"results": []})),
1383 )
1384 .expect(1)
1385 .mount(&server)
1386 .await;
1387
1388 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1389 let api = ConfluenceApi::new(client);
1390 let children = api.get_children("12345").await.unwrap();
1391 assert!(children.is_empty());
1392 }
1393
1394 #[tokio::test]
1395 async fn get_children_pagination() {
1396 let server = wiremock::MockServer::start().await;
1397
1398 wiremock::Mock::given(wiremock::matchers::method("GET"))
1399 .and(wiremock::matchers::path(
1400 "/wiki/rest/api/content/12345/child/page",
1401 ))
1402 .and(wiremock::matchers::query_param_is_missing("start"))
1403 .respond_with(
1404 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1405 "results": [{"id": "111", "title": "First", "status": "current"}],
1406 "_links": {
1407 "next": "/wiki/rest/api/content/12345/child/page?limit=50&start=50"
1408 }
1409 })),
1410 )
1411 .expect(1)
1412 .mount(&server)
1413 .await;
1414
1415 wiremock::Mock::given(wiremock::matchers::method("GET"))
1416 .and(wiremock::matchers::path(
1417 "/wiki/rest/api/content/12345/child/page",
1418 ))
1419 .and(wiremock::matchers::query_param("start", "50"))
1420 .respond_with(
1421 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1422 "results": [{"id": "222", "title": "Second", "status": "current"}]
1423 })),
1424 )
1425 .expect(1)
1426 .mount(&server)
1427 .await;
1428
1429 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1430 let api = ConfluenceApi::new(client);
1431 let children = api.get_children("12345").await.unwrap();
1432 assert_eq!(children.len(), 2);
1433 assert_eq!(children[0].status, "current");
1434 assert_eq!(children[0].parent_id.as_deref(), Some("12345"));
1435 }
1436
1437 #[tokio::test]
1438 async fn get_space_root_pages_success() {
1439 let server = wiremock::MockServer::start().await;
1440
1441 wiremock::Mock::given(wiremock::matchers::method("GET"))
1442 .and(wiremock::matchers::path("/wiki/api/v2/spaces/98765/pages"))
1443 .and(wiremock::matchers::query_param("depth", "root"))
1444 .respond_with(
1445 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1446 "results": [
1447 {"id": "111", "title": "Top One", "status": "current"},
1448 {"id": "222", "title": "Top Two", "status": "draft", "parentId": null}
1449 ]
1450 })),
1451 )
1452 .expect(1)
1453 .mount(&server)
1454 .await;
1455
1456 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1457 let api = ConfluenceApi::new(client);
1458 let pages = api.get_space_root_pages("98765").await.unwrap();
1459 assert_eq!(pages.len(), 2);
1460 assert_eq!(pages[0].id, "111");
1461 assert_eq!(pages[0].status, "current");
1462 assert_eq!(pages[1].status, "draft");
1463 }
1464
1465 #[tokio::test]
1466 async fn get_space_root_pages_empty() {
1467 let server = wiremock::MockServer::start().await;
1468
1469 wiremock::Mock::given(wiremock::matchers::method("GET"))
1470 .and(wiremock::matchers::path("/wiki/api/v2/spaces/98765/pages"))
1471 .respond_with(
1472 wiremock::ResponseTemplate::new(200)
1473 .set_body_json(serde_json::json!({"results": []})),
1474 )
1475 .expect(1)
1476 .mount(&server)
1477 .await;
1478
1479 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1480 let api = ConfluenceApi::new(client);
1481 let pages = api.get_space_root_pages("98765").await.unwrap();
1482 assert!(pages.is_empty());
1483 }
1484
1485 #[tokio::test]
1486 async fn get_space_root_pages_api_error() {
1487 let server = wiremock::MockServer::start().await;
1488
1489 wiremock::Mock::given(wiremock::matchers::method("GET"))
1490 .and(wiremock::matchers::path("/wiki/api/v2/spaces/99999/pages"))
1491 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
1492 .expect(1)
1493 .mount(&server)
1494 .await;
1495
1496 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1497 let api = ConfluenceApi::new(client);
1498 let err = api.get_space_root_pages("99999").await.unwrap_err();
1499 assert!(err.to_string().contains("403"));
1500 }
1501
1502 #[tokio::test]
1503 async fn get_space_root_pages_pagination() {
1504 let server = wiremock::MockServer::start().await;
1505
1506 wiremock::Mock::given(wiremock::matchers::method("GET"))
1507 .and(wiremock::matchers::path("/wiki/api/v2/spaces/98765/pages"))
1508 .and(wiremock::matchers::query_param_is_missing("cursor"))
1509 .respond_with(
1510 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1511 "results": [{"id": "111", "title": "A", "status": "current"}],
1512 "_links": {
1513 "next": "/wiki/api/v2/spaces/98765/pages?depth=root&cursor=page2"
1514 }
1515 })),
1516 )
1517 .expect(1)
1518 .mount(&server)
1519 .await;
1520
1521 wiremock::Mock::given(wiremock::matchers::method("GET"))
1522 .and(wiremock::matchers::path("/wiki/api/v2/spaces/98765/pages"))
1523 .and(wiremock::matchers::query_param("cursor", "page2"))
1524 .respond_with(
1525 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1526 "results": [{"id": "222", "title": "B", "status": "current"}]
1527 })),
1528 )
1529 .expect(1)
1530 .mount(&server)
1531 .await;
1532
1533 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1534 let api = ConfluenceApi::new(client);
1535 let pages = api.get_space_root_pages("98765").await.unwrap();
1536 assert_eq!(pages.len(), 2);
1537 assert_eq!(pages[0].id, "111");
1538 assert_eq!(pages[1].id, "222");
1539 }
1540
1541 #[tokio::test]
1542 async fn get_children_api_error() {
1543 let server = wiremock::MockServer::start().await;
1544
1545 wiremock::Mock::given(wiremock::matchers::method("GET"))
1546 .and(wiremock::matchers::path(
1547 "/wiki/rest/api/content/99999/child/page",
1548 ))
1549 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
1550 .expect(1)
1551 .mount(&server)
1552 .await;
1553
1554 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1555 let api = ConfluenceApi::new(client);
1556 let err = api.get_children("99999").await.unwrap_err();
1557 assert!(err.to_string().contains("404"));
1558 }
1559
1560 #[tokio::test]
1561 async fn resolve_space_key_fallback_on_error() {
1562 let server = wiremock::MockServer::start().await;
1563
1564 wiremock::Mock::given(wiremock::matchers::method("GET"))
1565 .and(wiremock::matchers::path("/wiki/api/v2/spaces/unknown"))
1566 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
1567 .mount(&server)
1568 .await;
1569
1570 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1571 let api = ConfluenceApi::new(client);
1572 let key = api.resolve_space_key("unknown").await.unwrap();
1573 assert_eq!(key, "unknown");
1575 }
1576
1577 #[tokio::test]
1580 async fn get_page_comments_success() {
1581 let server = wiremock::MockServer::start().await;
1582
1583 wiremock::Mock::given(wiremock::matchers::method("GET"))
1584 .and(wiremock::matchers::path(
1585 "/wiki/api/v2/pages/12345/footer-comments",
1586 ))
1587 .respond_with(
1588 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1589 "results": [
1590 {
1591 "id": "100",
1592 "version": {
1593 "authorId": "user-abc",
1594 "createdAt": "2026-04-01T10:00:00.000Z"
1595 },
1596 "body": {
1597 "atlas_doc_format": {
1598 "value": "{\"version\":1,\"type\":\"doc\",\"content\":[]}"
1599 }
1600 }
1601 },
1602 {
1603 "id": "101",
1604 "version": {
1605 "authorId": "user-def",
1606 "createdAt": "2026-04-02T14:00:00.000Z"
1607 }
1608 }
1609 ]
1610 })),
1611 )
1612 .expect(1)
1613 .mount(&server)
1614 .await;
1615
1616 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1617 let api = ConfluenceApi::new(client);
1618 let comments = api.get_page_comments("12345").await.unwrap();
1619
1620 assert_eq!(comments.len(), 2);
1621 assert_eq!(comments[0].id, "100");
1622 assert_eq!(comments[0].author, "user-abc");
1623 assert!(comments[0].body_adf.is_some());
1624 assert_eq!(comments[1].id, "101");
1625 assert!(comments[1].body_adf.is_none());
1626 }
1627
1628 #[tokio::test]
1629 async fn get_page_comments_malformed_adf_body() {
1630 let server = wiremock::MockServer::start().await;
1631
1632 wiremock::Mock::given(wiremock::matchers::method("GET"))
1633 .and(wiremock::matchers::path(
1634 "/wiki/api/v2/pages/12345/footer-comments",
1635 ))
1636 .respond_with(
1637 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1638 "results": [
1639 {
1640 "id": "100",
1641 "version": {
1642 "authorId": "user-abc",
1643 "createdAt": "2026-04-01T10:00:00.000Z"
1644 },
1645 "body": {
1646 "atlas_doc_format": {
1647 "value": "{ invalid json }"
1648 }
1649 }
1650 }
1651 ]
1652 })),
1653 )
1654 .expect(1)
1655 .mount(&server)
1656 .await;
1657
1658 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1659 let api = ConfluenceApi::new(client);
1660 let comments = api.get_page_comments("12345").await.unwrap();
1661
1662 assert_eq!(comments.len(), 1);
1663 assert_eq!(comments[0].id, "100");
1664 assert!(comments[0].body_adf.is_none());
1666 }
1667
1668 #[tokio::test]
1669 async fn get_page_comments_missing_version() {
1670 let server = wiremock::MockServer::start().await;
1671
1672 wiremock::Mock::given(wiremock::matchers::method("GET"))
1673 .and(wiremock::matchers::path(
1674 "/wiki/api/v2/pages/12345/footer-comments",
1675 ))
1676 .respond_with(
1677 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1678 "results": [
1679 {
1680 "id": "100"
1681 }
1682 ]
1683 })),
1684 )
1685 .expect(1)
1686 .mount(&server)
1687 .await;
1688
1689 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1690 let api = ConfluenceApi::new(client);
1691 let comments = api.get_page_comments("12345").await.unwrap();
1692
1693 assert_eq!(comments.len(), 1);
1694 assert_eq!(comments[0].author, "");
1695 assert_eq!(comments[0].created, "");
1696 assert!(comments[0].body_adf.is_none());
1697 }
1698
1699 #[tokio::test]
1700 async fn get_page_comments_empty() {
1701 let server = wiremock::MockServer::start().await;
1702
1703 wiremock::Mock::given(wiremock::matchers::method("GET"))
1704 .and(wiremock::matchers::path(
1705 "/wiki/api/v2/pages/12345/footer-comments",
1706 ))
1707 .respond_with(
1708 wiremock::ResponseTemplate::new(200)
1709 .set_body_json(serde_json::json!({"results": []})),
1710 )
1711 .expect(1)
1712 .mount(&server)
1713 .await;
1714
1715 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1716 let api = ConfluenceApi::new(client);
1717 let comments = api.get_page_comments("12345").await.unwrap();
1718 assert!(comments.is_empty());
1719 }
1720
1721 #[tokio::test]
1722 async fn get_page_comments_api_error() {
1723 let server = wiremock::MockServer::start().await;
1724
1725 wiremock::Mock::given(wiremock::matchers::method("GET"))
1726 .and(wiremock::matchers::path(
1727 "/wiki/api/v2/pages/99999/footer-comments",
1728 ))
1729 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
1730 .expect(1)
1731 .mount(&server)
1732 .await;
1733
1734 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1735 let api = ConfluenceApi::new(client);
1736 let err = api.get_page_comments("99999").await.unwrap_err();
1737 assert!(err.to_string().contains("404"));
1738 }
1739
1740 #[tokio::test]
1741 async fn get_page_comments_with_pagination() {
1742 let server = wiremock::MockServer::start().await;
1743
1744 wiremock::Mock::given(wiremock::matchers::method("GET"))
1746 .and(wiremock::matchers::path(
1747 "/wiki/api/v2/pages/12345/footer-comments",
1748 ))
1749 .and(wiremock::matchers::query_param_is_missing("cursor"))
1750 .respond_with(
1751 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1752 "results": [
1753 {
1754 "id": "100",
1755 "version": {
1756 "authorId": "user-abc",
1757 "createdAt": "2026-04-01T10:00:00.000Z"
1758 },
1759 "body": {
1760 "atlas_doc_format": {
1761 "value": "{\"version\":1,\"type\":\"doc\",\"content\":[]}"
1762 }
1763 }
1764 }
1765 ],
1766 "_links": {
1767 "next": "/wiki/api/v2/pages/12345/footer-comments?body-format=atlas_doc_format&cursor=page2"
1768 }
1769 })),
1770 )
1771 .expect(1)
1772 .mount(&server)
1773 .await;
1774
1775 wiremock::Mock::given(wiremock::matchers::method("GET"))
1777 .and(wiremock::matchers::path(
1778 "/wiki/api/v2/pages/12345/footer-comments",
1779 ))
1780 .and(wiremock::matchers::query_param("cursor", "page2"))
1781 .respond_with(
1782 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1783 "results": [
1784 {
1785 "id": "101",
1786 "version": {
1787 "authorId": "user-def",
1788 "createdAt": "2026-04-02T14:00:00.000Z"
1789 }
1790 }
1791 ]
1792 })),
1793 )
1794 .expect(1)
1795 .mount(&server)
1796 .await;
1797
1798 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1799 let api = ConfluenceApi::new(client);
1800 let comments = api.get_page_comments("12345").await.unwrap();
1801
1802 assert_eq!(comments.len(), 2);
1803 assert_eq!(comments[0].id, "100");
1804 assert_eq!(comments[0].author, "user-abc");
1805 assert!(comments[0].body_adf.is_some());
1806 assert_eq!(comments[1].id, "101");
1807 assert_eq!(comments[1].author, "user-def");
1808 }
1809
1810 #[tokio::test]
1811 async fn get_page_comments_pagination_stops_on_empty_page() {
1812 let server = wiremock::MockServer::start().await;
1813
1814 wiremock::Mock::given(wiremock::matchers::method("GET"))
1817 .and(wiremock::matchers::path(
1818 "/wiki/api/v2/pages/12345/footer-comments",
1819 ))
1820 .respond_with(
1821 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1822 "results": [],
1823 "_links": {
1824 "next": "/wiki/api/v2/pages/12345/footer-comments?cursor=loop"
1825 }
1826 })),
1827 )
1828 .expect(1)
1829 .mount(&server)
1830 .await;
1831
1832 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1833 let api = ConfluenceApi::new(client);
1834 let comments = api.get_page_comments("12345").await.unwrap();
1835 assert!(comments.is_empty());
1836 }
1837
1838 #[tokio::test]
1841 async fn add_page_comment_success() {
1842 let server = wiremock::MockServer::start().await;
1843
1844 wiremock::Mock::given(wiremock::matchers::method("POST"))
1845 .and(wiremock::matchers::path("/wiki/api/v2/footer-comments"))
1846 .respond_with(
1847 wiremock::ResponseTemplate::new(200)
1848 .set_body_json(serde_json::json!({"id": "200"})),
1849 )
1850 .expect(1)
1851 .mount(&server)
1852 .await;
1853
1854 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1855 let api = ConfluenceApi::new(client);
1856 let adf = AdfDocument::new();
1857 let result = api.add_page_comment("12345", &adf).await;
1858 assert!(result.is_ok());
1859 }
1860
1861 #[tokio::test]
1862 async fn add_page_comment_api_error() {
1863 let server = wiremock::MockServer::start().await;
1864
1865 wiremock::Mock::given(wiremock::matchers::method("POST"))
1866 .and(wiremock::matchers::path("/wiki/api/v2/footer-comments"))
1867 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
1868 .expect(1)
1869 .mount(&server)
1870 .await;
1871
1872 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1873 let api = ConfluenceApi::new(client);
1874 let adf = AdfDocument::new();
1875 let err = api.add_page_comment("12345", &adf).await.unwrap_err();
1876 assert!(err.to_string().contains("403"));
1877 }
1878
1879 #[tokio::test]
1882 async fn get_labels_success() {
1883 let server = wiremock::MockServer::start().await;
1884
1885 wiremock::Mock::given(wiremock::matchers::method("GET"))
1886 .and(wiremock::matchers::path("/wiki/api/v2/pages/12345/labels"))
1887 .respond_with(
1888 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1889 "results": [
1890 {"id": "1", "name": "architecture", "prefix": "global"},
1891 {"id": "2", "name": "draft", "prefix": "global"}
1892 ]
1893 })),
1894 )
1895 .expect(1)
1896 .mount(&server)
1897 .await;
1898
1899 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1900 let api = ConfluenceApi::new(client);
1901 let labels = api.get_labels("12345").await.unwrap();
1902
1903 assert_eq!(labels.len(), 2);
1904 assert_eq!(labels[0].name, "architecture");
1905 assert_eq!(labels[0].prefix, "global");
1906 assert_eq!(labels[1].name, "draft");
1907 }
1908
1909 #[tokio::test]
1910 async fn get_labels_with_pagination() {
1911 let server = wiremock::MockServer::start().await;
1912
1913 wiremock::Mock::given(wiremock::matchers::method("GET"))
1915 .and(wiremock::matchers::path("/wiki/api/v2/pages/12345/labels"))
1916 .and(wiremock::matchers::query_param_is_missing("cursor"))
1917 .respond_with(
1918 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1919 "results": [
1920 {"id": "1", "name": "architecture", "prefix": "global"}
1921 ],
1922 "_links": {
1923 "next": "/wiki/api/v2/pages/12345/labels?cursor=page2"
1924 }
1925 })),
1926 )
1927 .expect(1)
1928 .mount(&server)
1929 .await;
1930
1931 wiremock::Mock::given(wiremock::matchers::method("GET"))
1933 .and(wiremock::matchers::path("/wiki/api/v2/pages/12345/labels"))
1934 .and(wiremock::matchers::query_param("cursor", "page2"))
1935 .respond_with(
1936 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1937 "results": [
1938 {"id": "2", "name": "draft", "prefix": "global"}
1939 ]
1940 })),
1941 )
1942 .expect(1)
1943 .mount(&server)
1944 .await;
1945
1946 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1947 let api = ConfluenceApi::new(client);
1948 let labels = api.get_labels("12345").await.unwrap();
1949
1950 assert_eq!(labels.len(), 2);
1951 assert_eq!(labels[0].name, "architecture");
1952 assert_eq!(labels[1].name, "draft");
1953 }
1954
1955 #[tokio::test]
1956 async fn get_labels_empty() {
1957 let server = wiremock::MockServer::start().await;
1958
1959 wiremock::Mock::given(wiremock::matchers::method("GET"))
1960 .and(wiremock::matchers::path("/wiki/api/v2/pages/12345/labels"))
1961 .respond_with(
1962 wiremock::ResponseTemplate::new(200)
1963 .set_body_json(serde_json::json!({"results": []})),
1964 )
1965 .expect(1)
1966 .mount(&server)
1967 .await;
1968
1969 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1970 let api = ConfluenceApi::new(client);
1971 let labels = api.get_labels("12345").await.unwrap();
1972 assert!(labels.is_empty());
1973 }
1974
1975 #[tokio::test]
1976 async fn get_labels_api_error() {
1977 let server = wiremock::MockServer::start().await;
1978
1979 wiremock::Mock::given(wiremock::matchers::method("GET"))
1980 .and(wiremock::matchers::path("/wiki/api/v2/pages/99999/labels"))
1981 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
1982 .expect(1)
1983 .mount(&server)
1984 .await;
1985
1986 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1987 let api = ConfluenceApi::new(client);
1988 let err = api.get_labels("99999").await.unwrap_err();
1989 assert!(err.to_string().contains("404"));
1990 }
1991
1992 #[tokio::test]
1995 async fn add_labels_success() {
1996 let server = wiremock::MockServer::start().await;
1997
1998 wiremock::Mock::given(wiremock::matchers::method("POST"))
1999 .and(wiremock::matchers::path(
2000 "/wiki/rest/api/content/12345/label",
2001 ))
2002 .respond_with(
2003 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2004 "results": [
2005 {"prefix": "global", "name": "architecture", "id": "1"},
2006 {"prefix": "global", "name": "draft", "id": "2"}
2007 ]
2008 })),
2009 )
2010 .expect(1)
2011 .mount(&server)
2012 .await;
2013
2014 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2015 let api = ConfluenceApi::new(client);
2016 let result = api
2017 .add_labels("12345", &["architecture".to_string(), "draft".to_string()])
2018 .await;
2019 assert!(result.is_ok());
2020 }
2021
2022 #[tokio::test]
2023 async fn add_labels_api_error() {
2024 let server = wiremock::MockServer::start().await;
2025
2026 wiremock::Mock::given(wiremock::matchers::method("POST"))
2027 .and(wiremock::matchers::path(
2028 "/wiki/rest/api/content/99999/label",
2029 ))
2030 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
2031 .expect(1)
2032 .mount(&server)
2033 .await;
2034
2035 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2036 let api = ConfluenceApi::new(client);
2037 let err = api
2038 .add_labels("99999", &["test".to_string()])
2039 .await
2040 .unwrap_err();
2041 assert!(err.to_string().contains("404"));
2042 }
2043
2044 #[tokio::test]
2047 async fn remove_label_success() {
2048 let server = wiremock::MockServer::start().await;
2049
2050 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
2051 .and(wiremock::matchers::path(
2052 "/wiki/rest/api/content/12345/label/architecture",
2053 ))
2054 .respond_with(wiremock::ResponseTemplate::new(204))
2055 .expect(1)
2056 .mount(&server)
2057 .await;
2058
2059 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2060 let api = ConfluenceApi::new(client);
2061 let result = api.remove_label("12345", "architecture").await;
2062 assert!(result.is_ok());
2063 }
2064
2065 #[tokio::test]
2066 async fn remove_label_api_error() {
2067 let server = wiremock::MockServer::start().await;
2068
2069 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
2070 .and(wiremock::matchers::path(
2071 "/wiki/rest/api/content/99999/label/missing",
2072 ))
2073 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
2074 .expect(1)
2075 .mount(&server)
2076 .await;
2077
2078 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
2079 let api = ConfluenceApi::new(client);
2080 let err = api.remove_label("99999", "missing").await.unwrap_err();
2081 assert!(err.to_string().contains("404"));
2082 }
2083
2084 #[test]
2087 fn confluence_label_entry_deserialization() {
2088 let json = r#"{"id": "1", "name": "architecture", "prefix": "global"}"#;
2089 let entry: ConfluenceLabelEntry = serde_json::from_str(json).unwrap();
2090 assert_eq!(entry.id, "1");
2091 assert_eq!(entry.name, "architecture");
2092 assert_eq!(entry.prefix, "global");
2093 }
2094
2095 #[test]
2096 fn confluence_add_label_entry_serialization() {
2097 let entry = ConfluenceAddLabelEntry {
2098 prefix: "global".to_string(),
2099 name: "test".to_string(),
2100 };
2101 let json = serde_json::to_value(&entry).unwrap();
2102 assert_eq!(json["prefix"], "global");
2103 assert_eq!(json["name"], "test");
2104 }
2105}