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}
92
93#[derive(Deserialize)]
94struct ConfluenceChildrenLinks {
95 next: Option<String>,
96}
97
98#[derive(Debug, Clone)]
100pub struct ChildPage {
101 pub id: String,
103 pub title: String,
105}
106
107#[derive(Debug, Clone, Serialize)]
111pub struct ConfluenceComment {
112 pub id: String,
114 pub author: String,
116 pub body_adf: Option<serde_json::Value>,
118 pub created: String,
120}
121
122#[derive(Deserialize)]
123struct ConfluenceCommentsResponse {
124 results: Vec<ConfluenceCommentEntry>,
125}
126
127#[derive(Deserialize)]
128struct ConfluenceCommentEntry {
129 id: String,
130 #[serde(default)]
131 version: Option<ConfluenceCommentVersion>,
132 #[serde(default)]
133 body: Option<ConfluenceCommentBody>,
134}
135
136#[derive(Deserialize)]
137struct ConfluenceCommentVersion {
138 #[serde(rename = "authorId", default)]
139 author_id: Option<String>,
140 #[serde(rename = "createdAt", default)]
141 created_at: Option<String>,
142}
143
144#[derive(Deserialize)]
145struct ConfluenceCommentBody {
146 atlas_doc_format: Option<ConfluenceAtlasDoc>,
147}
148
149#[derive(Serialize)]
150struct ConfluenceAddCommentRequest {
151 #[serde(rename = "pageId")]
152 page_id: String,
153 body: ConfluenceUpdateBody,
154}
155
156#[derive(Deserialize)]
159struct ConfluenceLabelsResponse {
160 results: Vec<ConfluenceLabelEntry>,
161 #[serde(rename = "_links", default)]
162 links: Option<ConfluenceLabelsLinks>,
163}
164
165#[derive(Deserialize)]
166struct ConfluenceLabelEntry {
167 id: String,
168 name: String,
169 prefix: String,
170}
171
172#[derive(Deserialize)]
173struct ConfluenceLabelsLinks {
174 next: Option<String>,
175}
176
177#[derive(Debug, Clone, Serialize)]
179pub struct ConfluenceLabel {
180 pub id: String,
182 pub name: String,
184 pub prefix: String,
186}
187
188#[derive(Serialize)]
189struct ConfluenceAddLabelEntry {
190 prefix: String,
191 name: String,
192}
193
194#[derive(Serialize)]
197struct ConfluenceCreateRequest {
198 #[serde(rename = "spaceId")]
199 space_id: String,
200 title: String,
201 body: ConfluenceUpdateBody,
202 #[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
203 parent_id: Option<String>,
204 status: String,
205}
206
207#[derive(Deserialize)]
208struct ConfluenceCreateResponse {
209 id: String,
210}
211
212#[derive(Serialize)]
215struct ConfluenceUpdateRequest {
216 id: String,
217 status: String,
218 title: String,
219 body: ConfluenceUpdateBody,
220 version: ConfluenceUpdateVersion,
221}
222
223#[derive(Serialize)]
224struct ConfluenceUpdateBody {
225 representation: String,
226 value: String,
227}
228
229#[derive(Serialize)]
230struct ConfluenceUpdateVersion {
231 number: u32,
232 message: Option<String>,
233}
234
235impl AtlassianApi for ConfluenceApi {
236 fn get_content<'a>(
237 &'a self,
238 id: &'a str,
239 ) -> Pin<Box<dyn Future<Output = Result<ContentItem>> + Send + 'a>> {
240 Box::pin(async move {
241 let url = format!(
242 "{}/wiki/api/v2/pages/{}?body-format=atlas_doc_format",
243 self.client.instance_url(),
244 id
245 );
246
247 let response = self
248 .client
249 .get_json(&url)
250 .await
251 .context("Failed to fetch Confluence page")?;
252
253 if !response.status().is_success() {
254 let status = response.status().as_u16();
255 let body = response.text().await.unwrap_or_default();
256 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
257 }
258
259 let page: ConfluencePageResponse = response
260 .json()
261 .await
262 .context("Failed to parse Confluence page response")?;
263
264 debug!(
265 page_id = page.id,
266 title = page.title,
267 "Fetched Confluence page"
268 );
269
270 let body_adf = if let Some(body) = &page.body {
272 if let Some(atlas_doc) = &body.atlas_doc_format {
273 if tracing::enabled!(tracing::Level::TRACE) {
274 if let Ok(pretty) =
275 serde_json::from_str::<serde_json::Value>(&atlas_doc.value)
276 .and_then(|v| serde_json::to_string_pretty(&v))
277 {
278 tracing::trace!("Original ADF from Confluence:\n{pretty}");
279 }
280 }
281 Some(
282 serde_json::from_str(&atlas_doc.value)
283 .context("Failed to parse ADF from Confluence body")?,
284 )
285 } else {
286 None
287 }
288 } else {
289 None
290 };
291
292 let space_key = self.resolve_space_key(&page.space_id).await?;
294
295 Ok(ContentItem {
296 id: page.id,
297 title: page.title,
298 body_adf,
299 metadata: ContentMetadata::Confluence {
300 space_key,
301 status: Some(page.status),
302 version: page.version.map(|v| v.number),
303 parent_id: page.parent_id,
304 },
305 })
306 })
307 }
308
309 fn update_content<'a>(
310 &'a self,
311 id: &'a str,
312 body_adf: &'a AdfDocument,
313 title: Option<&'a str>,
314 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>> {
315 Box::pin(async move {
316 let current = self.get_content(id).await?;
318 let current_version = match ¤t.metadata {
319 ContentMetadata::Confluence { version, .. } => version.unwrap_or(1),
320 ContentMetadata::Jira { .. } => 1,
321 };
322 let current_title = current.title;
323
324 let adf_json =
325 serde_json::to_string(body_adf).context("Failed to serialize ADF document")?;
326
327 debug!(
328 page_id = id,
329 version = current_version + 1,
330 adf_bytes = adf_json.len(),
331 "Updating Confluence page"
332 );
333 if tracing::enabled!(tracing::Level::TRACE) {
334 let pretty = serde_json::to_string_pretty(body_adf)
335 .unwrap_or_else(|e| format!("<serialization error: {e}>"));
336 tracing::trace!("ADF body for update:\n{pretty}");
337 }
338
339 let update = ConfluenceUpdateRequest {
340 id: id.to_string(),
341 status: "current".to_string(),
342 title: title.unwrap_or(¤t_title).to_string(),
343 body: ConfluenceUpdateBody {
344 representation: "atlas_doc_format".to_string(),
345 value: adf_json,
346 },
347 version: ConfluenceUpdateVersion {
348 number: current_version + 1,
349 message: None,
350 },
351 };
352
353 let url = format!("{}/wiki/api/v2/pages/{}", self.client.instance_url(), id);
354
355 let response = self
356 .client
357 .put_json(&url, &update)
358 .await
359 .context("Failed to update Confluence page")?;
360
361 if !response.status().is_success() {
362 let status = response.status().as_u16();
363 let body = response.text().await.unwrap_or_default();
364 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
365 }
366
367 Ok(())
368 })
369 }
370
371 fn verify_auth<'a>(&'a self) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>> {
372 Box::pin(async move {
374 let user = self.client.get_myself().await?;
375 Ok(user.display_name)
376 })
377 }
378
379 fn backend_name(&self) -> &'static str {
380 "confluence"
381 }
382}
383
384impl ConfluenceApi {
385 pub async fn resolve_space_id(&self, space_key: &str) -> Result<String> {
387 let url = format!(
388 "{}/wiki/api/v2/spaces?keys={}",
389 self.client.instance_url(),
390 space_key
391 );
392
393 let response = self
394 .client
395 .get_json(&url)
396 .await
397 .context("Failed to search Confluence spaces")?;
398
399 if !response.status().is_success() {
400 let status = response.status().as_u16();
401 let body = response.text().await.unwrap_or_default();
402 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
403 }
404
405 let resp: ConfluenceSpacesSearchResponse = response
406 .json()
407 .await
408 .context("Failed to parse Confluence spaces response")?;
409
410 resp.results
411 .first()
412 .map(|s| s.id.clone())
413 .ok_or_else(|| anyhow::anyhow!("Space with key \"{space_key}\" not found"))
414 }
415
416 pub async fn create_page(
418 &self,
419 space_key: &str,
420 title: &str,
421 body_adf: &AdfDocument,
422 parent_id: Option<&str>,
423 ) -> Result<String> {
424 let space_id = self.resolve_space_id(space_key).await?;
425
426 let adf_json =
427 serde_json::to_string(body_adf).context("Failed to serialize ADF document")?;
428
429 let request = ConfluenceCreateRequest {
430 space_id,
431 title: title.to_string(),
432 body: ConfluenceUpdateBody {
433 representation: "atlas_doc_format".to_string(),
434 value: adf_json,
435 },
436 parent_id: parent_id.map(String::from),
437 status: "current".to_string(),
438 };
439
440 let url = format!("{}/wiki/api/v2/pages", self.client.instance_url());
441
442 let response = self
443 .client
444 .post_json(&url, &request)
445 .await
446 .context("Failed to create Confluence page")?;
447
448 if !response.status().is_success() {
449 let status = response.status().as_u16();
450 let body = response.text().await.unwrap_or_default();
451 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
452 }
453
454 let resp: ConfluenceCreateResponse = response
455 .json()
456 .await
457 .context("Failed to parse Confluence create response")?;
458
459 Ok(resp.id)
460 }
461
462 pub async fn delete_page(&self, id: &str, purge: bool) -> Result<()> {
464 let mut url = format!("{}/wiki/api/v2/pages/{}", self.client.instance_url(), id);
465 if purge {
466 url.push_str("?purge=true");
467 }
468
469 let response = self.client.delete(&url).await?;
470
471 if !response.status().is_success() {
472 let status = response.status().as_u16();
473 let body = response.text().await.unwrap_or_default();
474 if status == 404 {
475 anyhow::bail!(
476 "Page {id} not found or insufficient permissions. \
477 Confluence returns 404 when the API user lacks space-level delete permission. \
478 Check Space Settings > Permissions."
479 );
480 }
481 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
482 }
483
484 Ok(())
485 }
486
487 pub async fn get_children(&self, page_id: &str) -> Result<Vec<ChildPage>> {
492 let mut all_children = Vec::new();
493 let mut url = format!(
494 "{}/wiki/rest/api/content/{}/child/page?limit=50",
495 self.client.instance_url(),
496 page_id
497 );
498
499 loop {
500 let response = self
501 .client
502 .get_json(&url)
503 .await
504 .context("Failed to fetch child pages")?;
505
506 if !response.status().is_success() {
507 let status = response.status().as_u16();
508 let body = response.text().await.unwrap_or_default();
509 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
510 }
511
512 let resp: ConfluenceChildrenResponse = response
513 .json()
514 .await
515 .context("Failed to parse children response")?;
516
517 let page_count = resp.results.len();
518 for child in resp.results {
519 all_children.push(ChildPage {
520 id: child.id,
521 title: child.title,
522 });
523 }
524
525 match resp.links.and_then(|l| l.next) {
526 Some(next_path) if page_count > 0 => {
527 url = format!("{}{}", self.client.instance_url(), next_path);
528 }
529 _ => break,
530 }
531 }
532
533 Ok(all_children)
534 }
535
536 pub async fn get_page_comments(&self, page_id: &str) -> Result<Vec<ConfluenceComment>> {
538 let url = format!(
539 "{}/wiki/api/v2/pages/{}/footer-comments?body-format=atlas_doc_format",
540 self.client.instance_url(),
541 page_id
542 );
543
544 let response = self
545 .client
546 .get_json(&url)
547 .await
548 .context("Failed to fetch Confluence page comments")?;
549
550 if !response.status().is_success() {
551 let status = response.status().as_u16();
552 let body = response.text().await.unwrap_or_default();
553 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
554 }
555
556 let resp: ConfluenceCommentsResponse = response
557 .json()
558 .await
559 .context("Failed to parse Confluence comments response")?;
560
561 Ok(resp
562 .results
563 .into_iter()
564 .map(|c| {
565 let body_adf = c.body.and_then(|b| {
566 b.atlas_doc_format
567 .and_then(|a| serde_json::from_str(&a.value).ok())
568 });
569 let author = c
570 .version
571 .as_ref()
572 .and_then(|v| v.author_id.clone())
573 .unwrap_or_default();
574 let created = c.version.and_then(|v| v.created_at).unwrap_or_default();
575 ConfluenceComment {
576 id: c.id,
577 author,
578 body_adf,
579 created,
580 }
581 })
582 .collect())
583 }
584
585 pub async fn add_page_comment(&self, page_id: &str, body_adf: &AdfDocument) -> Result<()> {
587 let adf_json =
588 serde_json::to_string(body_adf).context("Failed to serialize ADF document")?;
589
590 let request = ConfluenceAddCommentRequest {
591 page_id: page_id.to_string(),
592 body: ConfluenceUpdateBody {
593 representation: "atlas_doc_format".to_string(),
594 value: adf_json,
595 },
596 };
597
598 let url = format!("{}/wiki/api/v2/footer-comments", self.client.instance_url());
599
600 let response = self
601 .client
602 .post_json(&url, &request)
603 .await
604 .context("Failed to add Confluence page comment")?;
605
606 if !response.status().is_success() {
607 let status = response.status().as_u16();
608 let body = response.text().await.unwrap_or_default();
609 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
610 }
611
612 Ok(())
613 }
614
615 async fn resolve_space_key(&self, space_id: &str) -> Result<String> {
617 let url = format!(
618 "{}/wiki/api/v2/spaces/{}",
619 self.client.instance_url(),
620 space_id
621 );
622
623 let response = self
624 .client
625 .get_json(&url)
626 .await
627 .context("Failed to fetch Confluence space")?;
628
629 if !response.status().is_success() {
630 return Ok(space_id.to_string());
632 }
633
634 let space: ConfluenceSpaceResponse = response
635 .json()
636 .await
637 .context("Failed to parse Confluence space response")?;
638
639 Ok(space.key)
640 }
641
642 pub async fn get_labels(&self, page_id: &str) -> Result<Vec<ConfluenceLabel>> {
644 let mut all_labels = Vec::new();
645 let mut url = format!(
646 "{}/wiki/api/v2/pages/{}/labels",
647 self.client.instance_url(),
648 page_id
649 );
650
651 loop {
652 let response = self
653 .client
654 .get_json(&url)
655 .await
656 .context("Failed to fetch page labels")?;
657
658 if !response.status().is_success() {
659 let status = response.status().as_u16();
660 let body = response.text().await.unwrap_or_default();
661 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
662 }
663
664 let resp: ConfluenceLabelsResponse = response
665 .json()
666 .await
667 .context("Failed to parse labels response")?;
668
669 let page_count = resp.results.len();
670 for entry in resp.results {
671 all_labels.push(ConfluenceLabel {
672 id: entry.id,
673 name: entry.name,
674 prefix: entry.prefix,
675 });
676 }
677
678 match resp.links.and_then(|l| l.next) {
679 Some(next_path) if page_count > 0 => {
680 url = format!("{}{}", self.client.instance_url(), next_path);
681 }
682 _ => break,
683 }
684 }
685
686 Ok(all_labels)
687 }
688
689 pub async fn add_labels(&self, page_id: &str, labels: &[String]) -> Result<()> {
691 let url = format!(
692 "{}/wiki/rest/api/content/{}/label",
693 self.client.instance_url(),
694 page_id
695 );
696
697 let body: Vec<ConfluenceAddLabelEntry> = labels
698 .iter()
699 .map(|name| ConfluenceAddLabelEntry {
700 prefix: "global".to_string(),
701 name: name.clone(),
702 })
703 .collect();
704
705 let response = self
706 .client
707 .post_json(&url, &body)
708 .await
709 .context("Failed to add labels")?;
710
711 if !response.status().is_success() {
712 let status = response.status().as_u16();
713 let body = response.text().await.unwrap_or_default();
714 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
715 }
716
717 Ok(())
718 }
719
720 pub async fn remove_label(&self, page_id: &str, label_name: &str) -> Result<()> {
722 let url = format!(
723 "{}/wiki/rest/api/content/{}/label/{}",
724 self.client.instance_url(),
725 page_id,
726 label_name
727 );
728
729 let response = self.client.delete(&url).await?;
730
731 if !response.status().is_success() {
732 let status = response.status().as_u16();
733 let body = response.text().await.unwrap_or_default();
734 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
735 }
736
737 Ok(())
738 }
739}
740
741#[cfg(test)]
742#[allow(clippy::unwrap_used, clippy::expect_used)]
743mod tests {
744 use super::*;
745
746 #[test]
747 fn confluence_api_backend_name() {
748 let client =
749 AtlassianClient::new("https://org.atlassian.net", "user@test.com", "token").unwrap();
750 let api = ConfluenceApi::new(client);
751 assert_eq!(api.backend_name(), "confluence");
752 }
753
754 #[test]
755 fn confluence_page_response_deserialization() {
756 let json = r#"{
757 "id": "12345",
758 "title": "Test Page",
759 "status": "current",
760 "spaceId": "98765",
761 "version": {"number": 3},
762 "body": {
763 "atlas_doc_format": {
764 "value": "{\"version\":1,\"type\":\"doc\",\"content\":[]}"
765 }
766 },
767 "parentId": "11111"
768 }"#;
769 let page: ConfluencePageResponse = serde_json::from_str(json).unwrap();
770 assert_eq!(page.id, "12345");
771 assert_eq!(page.title, "Test Page");
772 assert_eq!(page.status, "current");
773 assert_eq!(page.space_id, "98765");
774 assert_eq!(page.version.unwrap().number, 3);
775 assert_eq!(page.parent_id.as_deref(), Some("11111"));
776
777 let body = page.body.unwrap();
778 let atlas_doc = body.atlas_doc_format.unwrap();
779 let adf: serde_json::Value = serde_json::from_str(&atlas_doc.value).unwrap();
780 assert_eq!(adf["version"], 1);
781 assert_eq!(adf["type"], "doc");
782 }
783
784 #[test]
785 fn confluence_page_response_minimal() {
786 let json = r#"{
787 "id": "99",
788 "title": "Minimal",
789 "status": "draft",
790 "spaceId": "1"
791 }"#;
792 let page: ConfluencePageResponse = serde_json::from_str(json).unwrap();
793 assert_eq!(page.id, "99");
794 assert!(page.version.is_none());
795 assert!(page.body.is_none());
796 assert!(page.parent_id.is_none());
797 }
798
799 #[test]
800 fn confluence_update_request_serialization() {
801 let req = ConfluenceUpdateRequest {
802 id: "12345".to_string(),
803 status: "current".to_string(),
804 title: "Updated Title".to_string(),
805 body: ConfluenceUpdateBody {
806 representation: "atlas_doc_format".to_string(),
807 value: r#"{"version":1,"type":"doc","content":[]}"#.to_string(),
808 },
809 version: ConfluenceUpdateVersion {
810 number: 4,
811 message: None,
812 },
813 };
814
815 let json = serde_json::to_value(&req).unwrap();
816 assert_eq!(json["id"], "12345");
817 assert_eq!(json["status"], "current");
818 assert_eq!(json["title"], "Updated Title");
819 assert_eq!(json["body"]["representation"], "atlas_doc_format");
820 assert_eq!(json["version"]["number"], 4);
821 }
822
823 #[test]
824 fn confluence_update_version_with_message() {
825 let req = ConfluenceUpdateRequest {
826 id: "1".to_string(),
827 status: "current".to_string(),
828 title: "T".to_string(),
829 body: ConfluenceUpdateBody {
830 representation: "atlas_doc_format".to_string(),
831 value: "{}".to_string(),
832 },
833 version: ConfluenceUpdateVersion {
834 number: 2,
835 message: Some("Updated via API".to_string()),
836 },
837 };
838 let json = serde_json::to_value(&req).unwrap();
839 assert_eq!(json["version"]["message"], "Updated via API");
840 }
841
842 #[test]
843 fn confluence_space_response_deserialization() {
844 let json = r#"{"key": "ENG"}"#;
845 let space: ConfluenceSpaceResponse = serde_json::from_str(json).unwrap();
846 assert_eq!(space.key, "ENG");
847 }
848
849 async fn setup_confluence_mock() -> (wiremock::MockServer, ConfluenceApi) {
851 let server = wiremock::MockServer::start().await;
852
853 let page_json = serde_json::json!({
854 "id": "12345",
855 "title": "Test Page",
856 "status": "current",
857 "spaceId": "98765",
858 "version": {"number": 3},
859 "body": {
860 "atlas_doc_format": {
861 "value": "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"Hello\"}]}]}"
862 }
863 },
864 "parentId": "11111"
865 });
866
867 wiremock::Mock::given(wiremock::matchers::method("GET"))
868 .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
869 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&page_json))
870 .mount(&server)
871 .await;
872
873 wiremock::Mock::given(wiremock::matchers::method("GET"))
874 .and(wiremock::matchers::path("/wiki/api/v2/spaces/98765"))
875 .respond_with(
876 wiremock::ResponseTemplate::new(200)
877 .set_body_json(serde_json::json!({"key": "ENG"})),
878 )
879 .mount(&server)
880 .await;
881
882 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
883 let api = ConfluenceApi::new(client);
884
885 (server, api)
886 }
887
888 #[tokio::test]
889 async fn get_content_success() {
890 use crate::atlassian::api::{AtlassianApi, ContentMetadata};
891
892 let (_server, api) = setup_confluence_mock().await;
893 let item = api.get_content("12345").await.unwrap();
894
895 assert_eq!(item.id, "12345");
896 assert_eq!(item.title, "Test Page");
897 assert!(item.body_adf.is_some());
898 match &item.metadata {
899 ContentMetadata::Confluence {
900 space_key,
901 status,
902 version,
903 parent_id,
904 } => {
905 assert_eq!(space_key, "ENG");
906 assert_eq!(status.as_deref(), Some("current"));
907 assert_eq!(*version, Some(3));
908 assert_eq!(parent_id.as_deref(), Some("11111"));
909 }
910 ContentMetadata::Jira { .. } => panic!("Expected Confluence metadata"),
911 }
912 }
913
914 #[tokio::test]
915 async fn get_content_api_error() {
916 use crate::atlassian::api::AtlassianApi;
917
918 let server = wiremock::MockServer::start().await;
919
920 wiremock::Mock::given(wiremock::matchers::method("GET"))
921 .and(wiremock::matchers::path("/wiki/api/v2/pages/99999"))
922 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
923 .mount(&server)
924 .await;
925
926 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
927 let api = ConfluenceApi::new(client);
928 let err = api.get_content("99999").await.unwrap_err();
929 assert!(err.to_string().contains("404"));
930 }
931
932 #[tokio::test]
933 async fn get_content_no_body() {
934 use crate::atlassian::api::AtlassianApi;
935
936 let server = wiremock::MockServer::start().await;
937
938 wiremock::Mock::given(wiremock::matchers::method("GET"))
939 .and(wiremock::matchers::path("/wiki/api/v2/pages/55555"))
940 .respond_with(
941 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
942 "id": "55555",
943 "title": "No Body",
944 "status": "draft",
945 "spaceId": "11111"
946 })),
947 )
948 .mount(&server)
949 .await;
950
951 wiremock::Mock::given(wiremock::matchers::method("GET"))
952 .and(wiremock::matchers::path("/wiki/api/v2/spaces/11111"))
953 .respond_with(
954 wiremock::ResponseTemplate::new(200)
955 .set_body_json(serde_json::json!({"key": "DEV"})),
956 )
957 .mount(&server)
958 .await;
959
960 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
961 let api = ConfluenceApi::new(client);
962 let item = api.get_content("55555").await.unwrap();
963 assert!(item.body_adf.is_none());
964 }
965
966 #[tokio::test]
967 async fn update_content_success() {
968 use crate::atlassian::api::AtlassianApi;
969
970 let (server, api) = setup_confluence_mock().await;
971
972 wiremock::Mock::given(wiremock::matchers::method("PUT"))
973 .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
974 .respond_with(wiremock::ResponseTemplate::new(200))
975 .mount(&server)
976 .await;
977
978 let adf = AdfDocument::new();
979 let result = api.update_content("12345", &adf, Some("New Title")).await;
980 assert!(result.is_ok());
981 }
982
983 #[tokio::test]
984 async fn update_content_api_error() {
985 use crate::atlassian::api::AtlassianApi;
986
987 let (server, api) = setup_confluence_mock().await;
988
989 wiremock::Mock::given(wiremock::matchers::method("PUT"))
990 .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
991 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
992 .mount(&server)
993 .await;
994
995 let adf = AdfDocument::new();
996 let err = api.update_content("12345", &adf, None).await.unwrap_err();
997 assert!(err.to_string().contains("403"));
998 }
999
1000 #[tokio::test]
1001 async fn verify_auth_success() {
1002 use crate::atlassian::api::AtlassianApi;
1003
1004 let server = wiremock::MockServer::start().await;
1005
1006 wiremock::Mock::given(wiremock::matchers::method("GET"))
1007 .and(wiremock::matchers::path("/rest/api/3/myself"))
1008 .respond_with(
1009 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1010 "displayName": "Alice",
1011 "accountId": "abc123"
1012 })),
1013 )
1014 .mount(&server)
1015 .await;
1016
1017 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1018 let api = ConfluenceApi::new(client);
1019 let name = api.verify_auth().await.unwrap();
1020 assert_eq!(name, "Alice");
1021 }
1022
1023 #[tokio::test]
1024 async fn resolve_space_id_success() {
1025 let server = wiremock::MockServer::start().await;
1026
1027 wiremock::Mock::given(wiremock::matchers::method("GET"))
1028 .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
1029 .respond_with(
1030 wiremock::ResponseTemplate::new(200)
1031 .set_body_json(serde_json::json!({"results": [{"id": "98765"}]})),
1032 )
1033 .mount(&server)
1034 .await;
1035
1036 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1037 let api = ConfluenceApi::new(client);
1038 let id = api.resolve_space_id("ENG").await.unwrap();
1039 assert_eq!(id, "98765");
1040 }
1041
1042 #[tokio::test]
1043 async fn resolve_space_id_not_found() {
1044 let server = wiremock::MockServer::start().await;
1045
1046 wiremock::Mock::given(wiremock::matchers::method("GET"))
1047 .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
1048 .respond_with(
1049 wiremock::ResponseTemplate::new(200)
1050 .set_body_json(serde_json::json!({"results": []})),
1051 )
1052 .mount(&server)
1053 .await;
1054
1055 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1056 let api = ConfluenceApi::new(client);
1057 let err = api.resolve_space_id("NOPE").await.unwrap_err();
1058 assert!(err.to_string().contains("not found"));
1059 }
1060
1061 #[tokio::test]
1062 async fn resolve_space_id_api_error() {
1063 let server = wiremock::MockServer::start().await;
1064
1065 wiremock::Mock::given(wiremock::matchers::method("GET"))
1066 .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
1067 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
1068 .mount(&server)
1069 .await;
1070
1071 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1072 let api = ConfluenceApi::new(client);
1073 let err = api.resolve_space_id("ENG").await.unwrap_err();
1074 assert!(err.to_string().contains("403"));
1075 }
1076
1077 #[tokio::test]
1078 async fn create_page_success() {
1079 let server = wiremock::MockServer::start().await;
1080
1081 wiremock::Mock::given(wiremock::matchers::method("GET"))
1083 .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
1084 .respond_with(
1085 wiremock::ResponseTemplate::new(200)
1086 .set_body_json(serde_json::json!({"results": [{"id": "98765"}]})),
1087 )
1088 .mount(&server)
1089 .await;
1090
1091 wiremock::Mock::given(wiremock::matchers::method("POST"))
1093 .and(wiremock::matchers::path("/wiki/api/v2/pages"))
1094 .respond_with(
1095 wiremock::ResponseTemplate::new(200)
1096 .set_body_json(serde_json::json!({"id": "54321"})),
1097 )
1098 .mount(&server)
1099 .await;
1100
1101 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1102 let api = ConfluenceApi::new(client);
1103 let adf = AdfDocument::new();
1104 let id = api
1105 .create_page("ENG", "New Page", &adf, None)
1106 .await
1107 .unwrap();
1108 assert_eq!(id, "54321");
1109 }
1110
1111 #[tokio::test]
1112 async fn create_page_with_parent() {
1113 let server = wiremock::MockServer::start().await;
1114
1115 wiremock::Mock::given(wiremock::matchers::method("GET"))
1116 .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
1117 .respond_with(
1118 wiremock::ResponseTemplate::new(200)
1119 .set_body_json(serde_json::json!({"results": [{"id": "98765"}]})),
1120 )
1121 .mount(&server)
1122 .await;
1123
1124 wiremock::Mock::given(wiremock::matchers::method("POST"))
1125 .and(wiremock::matchers::path("/wiki/api/v2/pages"))
1126 .respond_with(
1127 wiremock::ResponseTemplate::new(200)
1128 .set_body_json(serde_json::json!({"id": "54322"})),
1129 )
1130 .mount(&server)
1131 .await;
1132
1133 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1134 let api = ConfluenceApi::new(client);
1135 let adf = AdfDocument::new();
1136 let id = api
1137 .create_page("ENG", "Child Page", &adf, Some("11111"))
1138 .await
1139 .unwrap();
1140 assert_eq!(id, "54322");
1141 }
1142
1143 #[tokio::test]
1144 async fn create_page_api_error() {
1145 let server = wiremock::MockServer::start().await;
1146
1147 wiremock::Mock::given(wiremock::matchers::method("GET"))
1148 .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
1149 .respond_with(
1150 wiremock::ResponseTemplate::new(200)
1151 .set_body_json(serde_json::json!({"results": [{"id": "98765"}]})),
1152 )
1153 .mount(&server)
1154 .await;
1155
1156 wiremock::Mock::given(wiremock::matchers::method("POST"))
1157 .and(wiremock::matchers::path("/wiki/api/v2/pages"))
1158 .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Bad Request"))
1159 .mount(&server)
1160 .await;
1161
1162 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1163 let api = ConfluenceApi::new(client);
1164 let adf = AdfDocument::new();
1165 let err = api
1166 .create_page("ENG", "Fail", &adf, None)
1167 .await
1168 .unwrap_err();
1169 assert!(err.to_string().contains("400"));
1170 }
1171
1172 #[tokio::test]
1173 async fn delete_page_success() {
1174 let server = wiremock::MockServer::start().await;
1175
1176 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
1177 .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
1178 .respond_with(wiremock::ResponseTemplate::new(204))
1179 .expect(1)
1180 .mount(&server)
1181 .await;
1182
1183 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1184 let api = ConfluenceApi::new(client);
1185 let result = api.delete_page("12345", false).await;
1186 assert!(result.is_ok());
1187 }
1188
1189 #[tokio::test]
1190 async fn delete_page_with_purge() {
1191 let server = wiremock::MockServer::start().await;
1192
1193 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
1194 .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
1195 .and(wiremock::matchers::query_param("purge", "true"))
1196 .respond_with(wiremock::ResponseTemplate::new(204))
1197 .expect(1)
1198 .mount(&server)
1199 .await;
1200
1201 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1202 let api = ConfluenceApi::new(client);
1203 let result = api.delete_page("12345", true).await;
1204 assert!(result.is_ok());
1205 }
1206
1207 #[tokio::test]
1208 async fn delete_page_not_found_hints_permissions() {
1209 let server = wiremock::MockServer::start().await;
1210
1211 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
1212 .and(wiremock::matchers::path("/wiki/api/v2/pages/99999"))
1213 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
1214 .expect(1)
1215 .mount(&server)
1216 .await;
1217
1218 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1219 let api = ConfluenceApi::new(client);
1220 let err = api.delete_page("99999", false).await.unwrap_err();
1221 let msg = err.to_string();
1222 assert!(msg.contains("not found or insufficient permissions"));
1223 assert!(msg.contains("Space Settings"));
1224 }
1225
1226 #[tokio::test]
1227 async fn delete_page_forbidden() {
1228 let server = wiremock::MockServer::start().await;
1229
1230 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
1231 .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
1232 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
1233 .expect(1)
1234 .mount(&server)
1235 .await;
1236
1237 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1238 let api = ConfluenceApi::new(client);
1239 let err = api.delete_page("12345", false).await.unwrap_err();
1240 assert!(err.to_string().contains("403"));
1241 }
1242
1243 #[tokio::test]
1244 async fn get_children_success() {
1245 let server = wiremock::MockServer::start().await;
1246
1247 wiremock::Mock::given(wiremock::matchers::method("GET"))
1248 .and(wiremock::matchers::path(
1249 "/wiki/rest/api/content/12345/child/page",
1250 ))
1251 .respond_with(
1252 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1253 "results": [
1254 {"id": "111", "title": "Child One"},
1255 {"id": "222", "title": "Child Two"}
1256 ]
1257 })),
1258 )
1259 .expect(1)
1260 .mount(&server)
1261 .await;
1262
1263 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1264 let api = ConfluenceApi::new(client);
1265 let children = api.get_children("12345").await.unwrap();
1266
1267 assert_eq!(children.len(), 2);
1268 assert_eq!(children[0].id, "111");
1269 assert_eq!(children[0].title, "Child One");
1270 assert_eq!(children[1].id, "222");
1271 }
1272
1273 #[tokio::test]
1274 async fn get_children_empty() {
1275 let server = wiremock::MockServer::start().await;
1276
1277 wiremock::Mock::given(wiremock::matchers::method("GET"))
1278 .and(wiremock::matchers::path(
1279 "/wiki/rest/api/content/12345/child/page",
1280 ))
1281 .respond_with(
1282 wiremock::ResponseTemplate::new(200)
1283 .set_body_json(serde_json::json!({"results": []})),
1284 )
1285 .expect(1)
1286 .mount(&server)
1287 .await;
1288
1289 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1290 let api = ConfluenceApi::new(client);
1291 let children = api.get_children("12345").await.unwrap();
1292 assert!(children.is_empty());
1293 }
1294
1295 #[tokio::test]
1296 async fn get_children_api_error() {
1297 let server = wiremock::MockServer::start().await;
1298
1299 wiremock::Mock::given(wiremock::matchers::method("GET"))
1300 .and(wiremock::matchers::path(
1301 "/wiki/rest/api/content/99999/child/page",
1302 ))
1303 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
1304 .expect(1)
1305 .mount(&server)
1306 .await;
1307
1308 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1309 let api = ConfluenceApi::new(client);
1310 let err = api.get_children("99999").await.unwrap_err();
1311 assert!(err.to_string().contains("404"));
1312 }
1313
1314 #[tokio::test]
1315 async fn resolve_space_key_fallback_on_error() {
1316 let server = wiremock::MockServer::start().await;
1317
1318 wiremock::Mock::given(wiremock::matchers::method("GET"))
1319 .and(wiremock::matchers::path("/wiki/api/v2/spaces/unknown"))
1320 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
1321 .mount(&server)
1322 .await;
1323
1324 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1325 let api = ConfluenceApi::new(client);
1326 let key = api.resolve_space_key("unknown").await.unwrap();
1327 assert_eq!(key, "unknown");
1329 }
1330
1331 #[tokio::test]
1334 async fn get_page_comments_success() {
1335 let server = wiremock::MockServer::start().await;
1336
1337 wiremock::Mock::given(wiremock::matchers::method("GET"))
1338 .and(wiremock::matchers::path(
1339 "/wiki/api/v2/pages/12345/footer-comments",
1340 ))
1341 .respond_with(
1342 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1343 "results": [
1344 {
1345 "id": "100",
1346 "version": {
1347 "authorId": "user-abc",
1348 "createdAt": "2026-04-01T10:00:00.000Z"
1349 },
1350 "body": {
1351 "atlas_doc_format": {
1352 "value": "{\"version\":1,\"type\":\"doc\",\"content\":[]}"
1353 }
1354 }
1355 },
1356 {
1357 "id": "101",
1358 "version": {
1359 "authorId": "user-def",
1360 "createdAt": "2026-04-02T14:00:00.000Z"
1361 }
1362 }
1363 ]
1364 })),
1365 )
1366 .expect(1)
1367 .mount(&server)
1368 .await;
1369
1370 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1371 let api = ConfluenceApi::new(client);
1372 let comments = api.get_page_comments("12345").await.unwrap();
1373
1374 assert_eq!(comments.len(), 2);
1375 assert_eq!(comments[0].id, "100");
1376 assert_eq!(comments[0].author, "user-abc");
1377 assert!(comments[0].body_adf.is_some());
1378 assert_eq!(comments[1].id, "101");
1379 assert!(comments[1].body_adf.is_none());
1380 }
1381
1382 #[tokio::test]
1383 async fn get_page_comments_malformed_adf_body() {
1384 let server = wiremock::MockServer::start().await;
1385
1386 wiremock::Mock::given(wiremock::matchers::method("GET"))
1387 .and(wiremock::matchers::path(
1388 "/wiki/api/v2/pages/12345/footer-comments",
1389 ))
1390 .respond_with(
1391 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1392 "results": [
1393 {
1394 "id": "100",
1395 "version": {
1396 "authorId": "user-abc",
1397 "createdAt": "2026-04-01T10:00:00.000Z"
1398 },
1399 "body": {
1400 "atlas_doc_format": {
1401 "value": "{ invalid json }"
1402 }
1403 }
1404 }
1405 ]
1406 })),
1407 )
1408 .expect(1)
1409 .mount(&server)
1410 .await;
1411
1412 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1413 let api = ConfluenceApi::new(client);
1414 let comments = api.get_page_comments("12345").await.unwrap();
1415
1416 assert_eq!(comments.len(), 1);
1417 assert_eq!(comments[0].id, "100");
1418 assert!(comments[0].body_adf.is_none());
1420 }
1421
1422 #[tokio::test]
1423 async fn get_page_comments_missing_version() {
1424 let server = wiremock::MockServer::start().await;
1425
1426 wiremock::Mock::given(wiremock::matchers::method("GET"))
1427 .and(wiremock::matchers::path(
1428 "/wiki/api/v2/pages/12345/footer-comments",
1429 ))
1430 .respond_with(
1431 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1432 "results": [
1433 {
1434 "id": "100"
1435 }
1436 ]
1437 })),
1438 )
1439 .expect(1)
1440 .mount(&server)
1441 .await;
1442
1443 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1444 let api = ConfluenceApi::new(client);
1445 let comments = api.get_page_comments("12345").await.unwrap();
1446
1447 assert_eq!(comments.len(), 1);
1448 assert_eq!(comments[0].author, "");
1449 assert_eq!(comments[0].created, "");
1450 assert!(comments[0].body_adf.is_none());
1451 }
1452
1453 #[tokio::test]
1454 async fn get_page_comments_empty() {
1455 let server = wiremock::MockServer::start().await;
1456
1457 wiremock::Mock::given(wiremock::matchers::method("GET"))
1458 .and(wiremock::matchers::path(
1459 "/wiki/api/v2/pages/12345/footer-comments",
1460 ))
1461 .respond_with(
1462 wiremock::ResponseTemplate::new(200)
1463 .set_body_json(serde_json::json!({"results": []})),
1464 )
1465 .expect(1)
1466 .mount(&server)
1467 .await;
1468
1469 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1470 let api = ConfluenceApi::new(client);
1471 let comments = api.get_page_comments("12345").await.unwrap();
1472 assert!(comments.is_empty());
1473 }
1474
1475 #[tokio::test]
1476 async fn get_page_comments_api_error() {
1477 let server = wiremock::MockServer::start().await;
1478
1479 wiremock::Mock::given(wiremock::matchers::method("GET"))
1480 .and(wiremock::matchers::path(
1481 "/wiki/api/v2/pages/99999/footer-comments",
1482 ))
1483 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
1484 .expect(1)
1485 .mount(&server)
1486 .await;
1487
1488 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1489 let api = ConfluenceApi::new(client);
1490 let err = api.get_page_comments("99999").await.unwrap_err();
1491 assert!(err.to_string().contains("404"));
1492 }
1493
1494 #[tokio::test]
1497 async fn add_page_comment_success() {
1498 let server = wiremock::MockServer::start().await;
1499
1500 wiremock::Mock::given(wiremock::matchers::method("POST"))
1501 .and(wiremock::matchers::path("/wiki/api/v2/footer-comments"))
1502 .respond_with(
1503 wiremock::ResponseTemplate::new(200)
1504 .set_body_json(serde_json::json!({"id": "200"})),
1505 )
1506 .expect(1)
1507 .mount(&server)
1508 .await;
1509
1510 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1511 let api = ConfluenceApi::new(client);
1512 let adf = AdfDocument::new();
1513 let result = api.add_page_comment("12345", &adf).await;
1514 assert!(result.is_ok());
1515 }
1516
1517 #[tokio::test]
1518 async fn add_page_comment_api_error() {
1519 let server = wiremock::MockServer::start().await;
1520
1521 wiremock::Mock::given(wiremock::matchers::method("POST"))
1522 .and(wiremock::matchers::path("/wiki/api/v2/footer-comments"))
1523 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
1524 .expect(1)
1525 .mount(&server)
1526 .await;
1527
1528 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1529 let api = ConfluenceApi::new(client);
1530 let adf = AdfDocument::new();
1531 let err = api.add_page_comment("12345", &adf).await.unwrap_err();
1532 assert!(err.to_string().contains("403"));
1533 }
1534
1535 #[tokio::test]
1538 async fn get_labels_success() {
1539 let server = wiremock::MockServer::start().await;
1540
1541 wiremock::Mock::given(wiremock::matchers::method("GET"))
1542 .and(wiremock::matchers::path("/wiki/api/v2/pages/12345/labels"))
1543 .respond_with(
1544 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1545 "results": [
1546 {"id": "1", "name": "architecture", "prefix": "global"},
1547 {"id": "2", "name": "draft", "prefix": "global"}
1548 ]
1549 })),
1550 )
1551 .expect(1)
1552 .mount(&server)
1553 .await;
1554
1555 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1556 let api = ConfluenceApi::new(client);
1557 let labels = api.get_labels("12345").await.unwrap();
1558
1559 assert_eq!(labels.len(), 2);
1560 assert_eq!(labels[0].name, "architecture");
1561 assert_eq!(labels[0].prefix, "global");
1562 assert_eq!(labels[1].name, "draft");
1563 }
1564
1565 #[tokio::test]
1566 async fn get_labels_with_pagination() {
1567 let server = wiremock::MockServer::start().await;
1568
1569 wiremock::Mock::given(wiremock::matchers::method("GET"))
1571 .and(wiremock::matchers::path("/wiki/api/v2/pages/12345/labels"))
1572 .and(wiremock::matchers::query_param_is_missing("cursor"))
1573 .respond_with(
1574 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1575 "results": [
1576 {"id": "1", "name": "architecture", "prefix": "global"}
1577 ],
1578 "_links": {
1579 "next": "/wiki/api/v2/pages/12345/labels?cursor=page2"
1580 }
1581 })),
1582 )
1583 .expect(1)
1584 .mount(&server)
1585 .await;
1586
1587 wiremock::Mock::given(wiremock::matchers::method("GET"))
1589 .and(wiremock::matchers::path("/wiki/api/v2/pages/12345/labels"))
1590 .and(wiremock::matchers::query_param("cursor", "page2"))
1591 .respond_with(
1592 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1593 "results": [
1594 {"id": "2", "name": "draft", "prefix": "global"}
1595 ]
1596 })),
1597 )
1598 .expect(1)
1599 .mount(&server)
1600 .await;
1601
1602 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1603 let api = ConfluenceApi::new(client);
1604 let labels = api.get_labels("12345").await.unwrap();
1605
1606 assert_eq!(labels.len(), 2);
1607 assert_eq!(labels[0].name, "architecture");
1608 assert_eq!(labels[1].name, "draft");
1609 }
1610
1611 #[tokio::test]
1612 async fn get_labels_empty() {
1613 let server = wiremock::MockServer::start().await;
1614
1615 wiremock::Mock::given(wiremock::matchers::method("GET"))
1616 .and(wiremock::matchers::path("/wiki/api/v2/pages/12345/labels"))
1617 .respond_with(
1618 wiremock::ResponseTemplate::new(200)
1619 .set_body_json(serde_json::json!({"results": []})),
1620 )
1621 .expect(1)
1622 .mount(&server)
1623 .await;
1624
1625 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1626 let api = ConfluenceApi::new(client);
1627 let labels = api.get_labels("12345").await.unwrap();
1628 assert!(labels.is_empty());
1629 }
1630
1631 #[tokio::test]
1632 async fn get_labels_api_error() {
1633 let server = wiremock::MockServer::start().await;
1634
1635 wiremock::Mock::given(wiremock::matchers::method("GET"))
1636 .and(wiremock::matchers::path("/wiki/api/v2/pages/99999/labels"))
1637 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
1638 .expect(1)
1639 .mount(&server)
1640 .await;
1641
1642 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1643 let api = ConfluenceApi::new(client);
1644 let err = api.get_labels("99999").await.unwrap_err();
1645 assert!(err.to_string().contains("404"));
1646 }
1647
1648 #[tokio::test]
1651 async fn add_labels_success() {
1652 let server = wiremock::MockServer::start().await;
1653
1654 wiremock::Mock::given(wiremock::matchers::method("POST"))
1655 .and(wiremock::matchers::path(
1656 "/wiki/rest/api/content/12345/label",
1657 ))
1658 .respond_with(
1659 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1660 "results": [
1661 {"prefix": "global", "name": "architecture", "id": "1"},
1662 {"prefix": "global", "name": "draft", "id": "2"}
1663 ]
1664 })),
1665 )
1666 .expect(1)
1667 .mount(&server)
1668 .await;
1669
1670 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1671 let api = ConfluenceApi::new(client);
1672 let result = api
1673 .add_labels("12345", &["architecture".to_string(), "draft".to_string()])
1674 .await;
1675 assert!(result.is_ok());
1676 }
1677
1678 #[tokio::test]
1679 async fn add_labels_api_error() {
1680 let server = wiremock::MockServer::start().await;
1681
1682 wiremock::Mock::given(wiremock::matchers::method("POST"))
1683 .and(wiremock::matchers::path(
1684 "/wiki/rest/api/content/99999/label",
1685 ))
1686 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
1687 .expect(1)
1688 .mount(&server)
1689 .await;
1690
1691 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1692 let api = ConfluenceApi::new(client);
1693 let err = api
1694 .add_labels("99999", &["test".to_string()])
1695 .await
1696 .unwrap_err();
1697 assert!(err.to_string().contains("404"));
1698 }
1699
1700 #[tokio::test]
1703 async fn remove_label_success() {
1704 let server = wiremock::MockServer::start().await;
1705
1706 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
1707 .and(wiremock::matchers::path(
1708 "/wiki/rest/api/content/12345/label/architecture",
1709 ))
1710 .respond_with(wiremock::ResponseTemplate::new(204))
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 result = api.remove_label("12345", "architecture").await;
1718 assert!(result.is_ok());
1719 }
1720
1721 #[tokio::test]
1722 async fn remove_label_api_error() {
1723 let server = wiremock::MockServer::start().await;
1724
1725 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
1726 .and(wiremock::matchers::path(
1727 "/wiki/rest/api/content/99999/label/missing",
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.remove_label("99999", "missing").await.unwrap_err();
1737 assert!(err.to_string().contains("404"));
1738 }
1739
1740 #[test]
1743 fn confluence_label_entry_deserialization() {
1744 let json = r#"{"id": "1", "name": "architecture", "prefix": "global"}"#;
1745 let entry: ConfluenceLabelEntry = serde_json::from_str(json).unwrap();
1746 assert_eq!(entry.id, "1");
1747 assert_eq!(entry.name, "architecture");
1748 assert_eq!(entry.prefix, "global");
1749 }
1750
1751 #[test]
1752 fn confluence_add_label_entry_serialization() {
1753 let entry = ConfluenceAddLabelEntry {
1754 prefix: "global".to_string(),
1755 name: "test".to_string(),
1756 };
1757 let json = serde_json::to_value(&entry).unwrap();
1758 assert_eq!(json["prefix"], "global");
1759 assert_eq!(json["name"], "test");
1760 }
1761}