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(Serialize)]
110struct ConfluenceCreateRequest {
111 #[serde(rename = "spaceId")]
112 space_id: String,
113 title: String,
114 body: ConfluenceUpdateBody,
115 #[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
116 parent_id: Option<String>,
117 status: String,
118}
119
120#[derive(Deserialize)]
121struct ConfluenceCreateResponse {
122 id: String,
123}
124
125#[derive(Serialize)]
128struct ConfluenceUpdateRequest {
129 id: String,
130 status: String,
131 title: String,
132 body: ConfluenceUpdateBody,
133 version: ConfluenceUpdateVersion,
134}
135
136#[derive(Serialize)]
137struct ConfluenceUpdateBody {
138 representation: String,
139 value: String,
140}
141
142#[derive(Serialize)]
143struct ConfluenceUpdateVersion {
144 number: u32,
145 message: Option<String>,
146}
147
148impl AtlassianApi for ConfluenceApi {
149 fn get_content<'a>(
150 &'a self,
151 id: &'a str,
152 ) -> Pin<Box<dyn Future<Output = Result<ContentItem>> + Send + 'a>> {
153 Box::pin(async move {
154 let url = format!(
155 "{}/wiki/api/v2/pages/{}?body-format=atlas_doc_format",
156 self.client.instance_url(),
157 id
158 );
159
160 let response = self
161 .client
162 .get_json(&url)
163 .await
164 .context("Failed to fetch Confluence page")?;
165
166 if !response.status().is_success() {
167 let status = response.status().as_u16();
168 let body = response.text().await.unwrap_or_default();
169 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
170 }
171
172 let page: ConfluencePageResponse = response
173 .json()
174 .await
175 .context("Failed to parse Confluence page response")?;
176
177 debug!(
178 page_id = page.id,
179 title = page.title,
180 "Fetched Confluence page"
181 );
182
183 let body_adf = if let Some(body) = &page.body {
185 if let Some(atlas_doc) = &body.atlas_doc_format {
186 if tracing::enabled!(tracing::Level::TRACE) {
187 if let Ok(pretty) =
188 serde_json::from_str::<serde_json::Value>(&atlas_doc.value)
189 .and_then(|v| serde_json::to_string_pretty(&v))
190 {
191 tracing::trace!("Original ADF from Confluence:\n{pretty}");
192 }
193 }
194 Some(
195 serde_json::from_str(&atlas_doc.value)
196 .context("Failed to parse ADF from Confluence body")?,
197 )
198 } else {
199 None
200 }
201 } else {
202 None
203 };
204
205 let space_key = self.resolve_space_key(&page.space_id).await?;
207
208 Ok(ContentItem {
209 id: page.id,
210 title: page.title,
211 body_adf,
212 metadata: ContentMetadata::Confluence {
213 space_key,
214 status: Some(page.status),
215 version: page.version.map(|v| v.number),
216 parent_id: page.parent_id,
217 },
218 })
219 })
220 }
221
222 fn update_content<'a>(
223 &'a self,
224 id: &'a str,
225 body_adf: &'a AdfDocument,
226 title: Option<&'a str>,
227 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>> {
228 Box::pin(async move {
229 let current = self.get_content(id).await?;
231 let current_version = match ¤t.metadata {
232 ContentMetadata::Confluence { version, .. } => version.unwrap_or(1),
233 ContentMetadata::Jira { .. } => 1,
234 };
235 let current_title = current.title;
236
237 let adf_json =
238 serde_json::to_string(body_adf).context("Failed to serialize ADF document")?;
239
240 debug!(
241 page_id = id,
242 version = current_version + 1,
243 adf_bytes = adf_json.len(),
244 "Updating Confluence page"
245 );
246 if tracing::enabled!(tracing::Level::TRACE) {
247 let pretty = serde_json::to_string_pretty(body_adf)
248 .unwrap_or_else(|e| format!("<serialization error: {e}>"));
249 tracing::trace!("ADF body for update:\n{pretty}");
250 }
251
252 let update = ConfluenceUpdateRequest {
253 id: id.to_string(),
254 status: "current".to_string(),
255 title: title.unwrap_or(¤t_title).to_string(),
256 body: ConfluenceUpdateBody {
257 representation: "atlas_doc_format".to_string(),
258 value: adf_json,
259 },
260 version: ConfluenceUpdateVersion {
261 number: current_version + 1,
262 message: None,
263 },
264 };
265
266 let url = format!("{}/wiki/api/v2/pages/{}", self.client.instance_url(), id);
267
268 let response = self
269 .client
270 .put_json(&url, &update)
271 .await
272 .context("Failed to update Confluence page")?;
273
274 if !response.status().is_success() {
275 let status = response.status().as_u16();
276 let body = response.text().await.unwrap_or_default();
277 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
278 }
279
280 Ok(())
281 })
282 }
283
284 fn verify_auth<'a>(&'a self) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>> {
285 Box::pin(async move {
287 let user = self.client.get_myself().await?;
288 Ok(user.display_name)
289 })
290 }
291
292 fn backend_name(&self) -> &'static str {
293 "confluence"
294 }
295}
296
297impl ConfluenceApi {
298 pub async fn resolve_space_id(&self, space_key: &str) -> Result<String> {
300 let url = format!(
301 "{}/wiki/api/v2/spaces?keys={}",
302 self.client.instance_url(),
303 space_key
304 );
305
306 let response = self
307 .client
308 .get_json(&url)
309 .await
310 .context("Failed to search Confluence spaces")?;
311
312 if !response.status().is_success() {
313 let status = response.status().as_u16();
314 let body = response.text().await.unwrap_or_default();
315 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
316 }
317
318 let resp: ConfluenceSpacesSearchResponse = response
319 .json()
320 .await
321 .context("Failed to parse Confluence spaces response")?;
322
323 resp.results
324 .first()
325 .map(|s| s.id.clone())
326 .ok_or_else(|| anyhow::anyhow!("Space with key \"{space_key}\" not found"))
327 }
328
329 pub async fn create_page(
331 &self,
332 space_key: &str,
333 title: &str,
334 body_adf: &AdfDocument,
335 parent_id: Option<&str>,
336 ) -> Result<String> {
337 let space_id = self.resolve_space_id(space_key).await?;
338
339 let adf_json =
340 serde_json::to_string(body_adf).context("Failed to serialize ADF document")?;
341
342 let request = ConfluenceCreateRequest {
343 space_id,
344 title: title.to_string(),
345 body: ConfluenceUpdateBody {
346 representation: "atlas_doc_format".to_string(),
347 value: adf_json,
348 },
349 parent_id: parent_id.map(String::from),
350 status: "current".to_string(),
351 };
352
353 let url = format!("{}/wiki/api/v2/pages", self.client.instance_url());
354
355 let response = self
356 .client
357 .post_json(&url, &request)
358 .await
359 .context("Failed to create 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 let resp: ConfluenceCreateResponse = response
368 .json()
369 .await
370 .context("Failed to parse Confluence create response")?;
371
372 Ok(resp.id)
373 }
374
375 pub async fn delete_page(&self, id: &str, purge: bool) -> Result<()> {
377 let mut url = format!("{}/wiki/api/v2/pages/{}", self.client.instance_url(), id);
378 if purge {
379 url.push_str("?purge=true");
380 }
381
382 let response = self.client.delete(&url).await?;
383
384 if !response.status().is_success() {
385 let status = response.status().as_u16();
386 let body = response.text().await.unwrap_or_default();
387 if status == 404 {
388 anyhow::bail!(
389 "Page {id} not found or insufficient permissions. \
390 Confluence returns 404 when the API user lacks space-level delete permission. \
391 Check Space Settings > Permissions."
392 );
393 }
394 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
395 }
396
397 Ok(())
398 }
399
400 pub async fn get_children(&self, page_id: &str) -> Result<Vec<ChildPage>> {
405 let mut all_children = Vec::new();
406 let mut url = format!(
407 "{}/wiki/rest/api/content/{}/child/page?limit=50",
408 self.client.instance_url(),
409 page_id
410 );
411
412 loop {
413 let response = self
414 .client
415 .get_json(&url)
416 .await
417 .context("Failed to fetch child pages")?;
418
419 if !response.status().is_success() {
420 let status = response.status().as_u16();
421 let body = response.text().await.unwrap_or_default();
422 return Err(AtlassianError::ApiRequestFailed { status, body }.into());
423 }
424
425 let resp: ConfluenceChildrenResponse = response
426 .json()
427 .await
428 .context("Failed to parse children response")?;
429
430 let page_count = resp.results.len();
431 for child in resp.results {
432 all_children.push(ChildPage {
433 id: child.id,
434 title: child.title,
435 });
436 }
437
438 match resp.links.and_then(|l| l.next) {
439 Some(next_path) if page_count > 0 => {
440 url = format!("{}{}", self.client.instance_url(), next_path);
441 }
442 _ => break,
443 }
444 }
445
446 Ok(all_children)
447 }
448
449 async fn resolve_space_key(&self, space_id: &str) -> Result<String> {
451 let url = format!(
452 "{}/wiki/api/v2/spaces/{}",
453 self.client.instance_url(),
454 space_id
455 );
456
457 let response = self
458 .client
459 .get_json(&url)
460 .await
461 .context("Failed to fetch Confluence space")?;
462
463 if !response.status().is_success() {
464 return Ok(space_id.to_string());
466 }
467
468 let space: ConfluenceSpaceResponse = response
469 .json()
470 .await
471 .context("Failed to parse Confluence space response")?;
472
473 Ok(space.key)
474 }
475}
476
477#[cfg(test)]
478#[allow(clippy::unwrap_used, clippy::expect_used)]
479mod tests {
480 use super::*;
481
482 #[test]
483 fn confluence_api_backend_name() {
484 let client =
485 AtlassianClient::new("https://org.atlassian.net", "user@test.com", "token").unwrap();
486 let api = ConfluenceApi::new(client);
487 assert_eq!(api.backend_name(), "confluence");
488 }
489
490 #[test]
491 fn confluence_page_response_deserialization() {
492 let json = r#"{
493 "id": "12345",
494 "title": "Test Page",
495 "status": "current",
496 "spaceId": "98765",
497 "version": {"number": 3},
498 "body": {
499 "atlas_doc_format": {
500 "value": "{\"version\":1,\"type\":\"doc\",\"content\":[]}"
501 }
502 },
503 "parentId": "11111"
504 }"#;
505 let page: ConfluencePageResponse = serde_json::from_str(json).unwrap();
506 assert_eq!(page.id, "12345");
507 assert_eq!(page.title, "Test Page");
508 assert_eq!(page.status, "current");
509 assert_eq!(page.space_id, "98765");
510 assert_eq!(page.version.unwrap().number, 3);
511 assert_eq!(page.parent_id.as_deref(), Some("11111"));
512
513 let body = page.body.unwrap();
514 let atlas_doc = body.atlas_doc_format.unwrap();
515 let adf: serde_json::Value = serde_json::from_str(&atlas_doc.value).unwrap();
516 assert_eq!(adf["version"], 1);
517 assert_eq!(adf["type"], "doc");
518 }
519
520 #[test]
521 fn confluence_page_response_minimal() {
522 let json = r#"{
523 "id": "99",
524 "title": "Minimal",
525 "status": "draft",
526 "spaceId": "1"
527 }"#;
528 let page: ConfluencePageResponse = serde_json::from_str(json).unwrap();
529 assert_eq!(page.id, "99");
530 assert!(page.version.is_none());
531 assert!(page.body.is_none());
532 assert!(page.parent_id.is_none());
533 }
534
535 #[test]
536 fn confluence_update_request_serialization() {
537 let req = ConfluenceUpdateRequest {
538 id: "12345".to_string(),
539 status: "current".to_string(),
540 title: "Updated Title".to_string(),
541 body: ConfluenceUpdateBody {
542 representation: "atlas_doc_format".to_string(),
543 value: r#"{"version":1,"type":"doc","content":[]}"#.to_string(),
544 },
545 version: ConfluenceUpdateVersion {
546 number: 4,
547 message: None,
548 },
549 };
550
551 let json = serde_json::to_value(&req).unwrap();
552 assert_eq!(json["id"], "12345");
553 assert_eq!(json["status"], "current");
554 assert_eq!(json["title"], "Updated Title");
555 assert_eq!(json["body"]["representation"], "atlas_doc_format");
556 assert_eq!(json["version"]["number"], 4);
557 }
558
559 #[test]
560 fn confluence_update_version_with_message() {
561 let req = ConfluenceUpdateRequest {
562 id: "1".to_string(),
563 status: "current".to_string(),
564 title: "T".to_string(),
565 body: ConfluenceUpdateBody {
566 representation: "atlas_doc_format".to_string(),
567 value: "{}".to_string(),
568 },
569 version: ConfluenceUpdateVersion {
570 number: 2,
571 message: Some("Updated via API".to_string()),
572 },
573 };
574 let json = serde_json::to_value(&req).unwrap();
575 assert_eq!(json["version"]["message"], "Updated via API");
576 }
577
578 #[test]
579 fn confluence_space_response_deserialization() {
580 let json = r#"{"key": "ENG"}"#;
581 let space: ConfluenceSpaceResponse = serde_json::from_str(json).unwrap();
582 assert_eq!(space.key, "ENG");
583 }
584
585 async fn setup_confluence_mock() -> (wiremock::MockServer, ConfluenceApi) {
587 let server = wiremock::MockServer::start().await;
588
589 let page_json = serde_json::json!({
590 "id": "12345",
591 "title": "Test Page",
592 "status": "current",
593 "spaceId": "98765",
594 "version": {"number": 3},
595 "body": {
596 "atlas_doc_format": {
597 "value": "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"Hello\"}]}]}"
598 }
599 },
600 "parentId": "11111"
601 });
602
603 wiremock::Mock::given(wiremock::matchers::method("GET"))
604 .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
605 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&page_json))
606 .mount(&server)
607 .await;
608
609 wiremock::Mock::given(wiremock::matchers::method("GET"))
610 .and(wiremock::matchers::path("/wiki/api/v2/spaces/98765"))
611 .respond_with(
612 wiremock::ResponseTemplate::new(200)
613 .set_body_json(serde_json::json!({"key": "ENG"})),
614 )
615 .mount(&server)
616 .await;
617
618 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
619 let api = ConfluenceApi::new(client);
620
621 (server, api)
622 }
623
624 #[tokio::test]
625 async fn get_content_success() {
626 use crate::atlassian::api::{AtlassianApi, ContentMetadata};
627
628 let (_server, api) = setup_confluence_mock().await;
629 let item = api.get_content("12345").await.unwrap();
630
631 assert_eq!(item.id, "12345");
632 assert_eq!(item.title, "Test Page");
633 assert!(item.body_adf.is_some());
634 match &item.metadata {
635 ContentMetadata::Confluence {
636 space_key,
637 status,
638 version,
639 parent_id,
640 } => {
641 assert_eq!(space_key, "ENG");
642 assert_eq!(status.as_deref(), Some("current"));
643 assert_eq!(*version, Some(3));
644 assert_eq!(parent_id.as_deref(), Some("11111"));
645 }
646 ContentMetadata::Jira { .. } => panic!("Expected Confluence metadata"),
647 }
648 }
649
650 #[tokio::test]
651 async fn get_content_api_error() {
652 use crate::atlassian::api::AtlassianApi;
653
654 let server = wiremock::MockServer::start().await;
655
656 wiremock::Mock::given(wiremock::matchers::method("GET"))
657 .and(wiremock::matchers::path("/wiki/api/v2/pages/99999"))
658 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
659 .mount(&server)
660 .await;
661
662 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
663 let api = ConfluenceApi::new(client);
664 let err = api.get_content("99999").await.unwrap_err();
665 assert!(err.to_string().contains("404"));
666 }
667
668 #[tokio::test]
669 async fn get_content_no_body() {
670 use crate::atlassian::api::AtlassianApi;
671
672 let server = wiremock::MockServer::start().await;
673
674 wiremock::Mock::given(wiremock::matchers::method("GET"))
675 .and(wiremock::matchers::path("/wiki/api/v2/pages/55555"))
676 .respond_with(
677 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
678 "id": "55555",
679 "title": "No Body",
680 "status": "draft",
681 "spaceId": "11111"
682 })),
683 )
684 .mount(&server)
685 .await;
686
687 wiremock::Mock::given(wiremock::matchers::method("GET"))
688 .and(wiremock::matchers::path("/wiki/api/v2/spaces/11111"))
689 .respond_with(
690 wiremock::ResponseTemplate::new(200)
691 .set_body_json(serde_json::json!({"key": "DEV"})),
692 )
693 .mount(&server)
694 .await;
695
696 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
697 let api = ConfluenceApi::new(client);
698 let item = api.get_content("55555").await.unwrap();
699 assert!(item.body_adf.is_none());
700 }
701
702 #[tokio::test]
703 async fn update_content_success() {
704 use crate::atlassian::api::AtlassianApi;
705
706 let (server, api) = setup_confluence_mock().await;
707
708 wiremock::Mock::given(wiremock::matchers::method("PUT"))
709 .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
710 .respond_with(wiremock::ResponseTemplate::new(200))
711 .mount(&server)
712 .await;
713
714 let adf = AdfDocument::new();
715 let result = api.update_content("12345", &adf, Some("New Title")).await;
716 assert!(result.is_ok());
717 }
718
719 #[tokio::test]
720 async fn update_content_api_error() {
721 use crate::atlassian::api::AtlassianApi;
722
723 let (server, api) = setup_confluence_mock().await;
724
725 wiremock::Mock::given(wiremock::matchers::method("PUT"))
726 .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
727 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
728 .mount(&server)
729 .await;
730
731 let adf = AdfDocument::new();
732 let err = api.update_content("12345", &adf, None).await.unwrap_err();
733 assert!(err.to_string().contains("403"));
734 }
735
736 #[tokio::test]
737 async fn verify_auth_success() {
738 use crate::atlassian::api::AtlassianApi;
739
740 let server = wiremock::MockServer::start().await;
741
742 wiremock::Mock::given(wiremock::matchers::method("GET"))
743 .and(wiremock::matchers::path("/rest/api/3/myself"))
744 .respond_with(
745 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
746 "displayName": "Alice",
747 "accountId": "abc123"
748 })),
749 )
750 .mount(&server)
751 .await;
752
753 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
754 let api = ConfluenceApi::new(client);
755 let name = api.verify_auth().await.unwrap();
756 assert_eq!(name, "Alice");
757 }
758
759 #[tokio::test]
760 async fn resolve_space_id_success() {
761 let server = wiremock::MockServer::start().await;
762
763 wiremock::Mock::given(wiremock::matchers::method("GET"))
764 .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
765 .respond_with(
766 wiremock::ResponseTemplate::new(200)
767 .set_body_json(serde_json::json!({"results": [{"id": "98765"}]})),
768 )
769 .mount(&server)
770 .await;
771
772 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
773 let api = ConfluenceApi::new(client);
774 let id = api.resolve_space_id("ENG").await.unwrap();
775 assert_eq!(id, "98765");
776 }
777
778 #[tokio::test]
779 async fn resolve_space_id_not_found() {
780 let server = wiremock::MockServer::start().await;
781
782 wiremock::Mock::given(wiremock::matchers::method("GET"))
783 .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
784 .respond_with(
785 wiremock::ResponseTemplate::new(200)
786 .set_body_json(serde_json::json!({"results": []})),
787 )
788 .mount(&server)
789 .await;
790
791 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
792 let api = ConfluenceApi::new(client);
793 let err = api.resolve_space_id("NOPE").await.unwrap_err();
794 assert!(err.to_string().contains("not found"));
795 }
796
797 #[tokio::test]
798 async fn resolve_space_id_api_error() {
799 let server = wiremock::MockServer::start().await;
800
801 wiremock::Mock::given(wiremock::matchers::method("GET"))
802 .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
803 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
804 .mount(&server)
805 .await;
806
807 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
808 let api = ConfluenceApi::new(client);
809 let err = api.resolve_space_id("ENG").await.unwrap_err();
810 assert!(err.to_string().contains("403"));
811 }
812
813 #[tokio::test]
814 async fn create_page_success() {
815 let server = wiremock::MockServer::start().await;
816
817 wiremock::Mock::given(wiremock::matchers::method("GET"))
819 .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
820 .respond_with(
821 wiremock::ResponseTemplate::new(200)
822 .set_body_json(serde_json::json!({"results": [{"id": "98765"}]})),
823 )
824 .mount(&server)
825 .await;
826
827 wiremock::Mock::given(wiremock::matchers::method("POST"))
829 .and(wiremock::matchers::path("/wiki/api/v2/pages"))
830 .respond_with(
831 wiremock::ResponseTemplate::new(200)
832 .set_body_json(serde_json::json!({"id": "54321"})),
833 )
834 .mount(&server)
835 .await;
836
837 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
838 let api = ConfluenceApi::new(client);
839 let adf = AdfDocument::new();
840 let id = api
841 .create_page("ENG", "New Page", &adf, None)
842 .await
843 .unwrap();
844 assert_eq!(id, "54321");
845 }
846
847 #[tokio::test]
848 async fn create_page_with_parent() {
849 let server = wiremock::MockServer::start().await;
850
851 wiremock::Mock::given(wiremock::matchers::method("GET"))
852 .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
853 .respond_with(
854 wiremock::ResponseTemplate::new(200)
855 .set_body_json(serde_json::json!({"results": [{"id": "98765"}]})),
856 )
857 .mount(&server)
858 .await;
859
860 wiremock::Mock::given(wiremock::matchers::method("POST"))
861 .and(wiremock::matchers::path("/wiki/api/v2/pages"))
862 .respond_with(
863 wiremock::ResponseTemplate::new(200)
864 .set_body_json(serde_json::json!({"id": "54322"})),
865 )
866 .mount(&server)
867 .await;
868
869 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
870 let api = ConfluenceApi::new(client);
871 let adf = AdfDocument::new();
872 let id = api
873 .create_page("ENG", "Child Page", &adf, Some("11111"))
874 .await
875 .unwrap();
876 assert_eq!(id, "54322");
877 }
878
879 #[tokio::test]
880 async fn create_page_api_error() {
881 let server = wiremock::MockServer::start().await;
882
883 wiremock::Mock::given(wiremock::matchers::method("GET"))
884 .and(wiremock::matchers::path("/wiki/api/v2/spaces"))
885 .respond_with(
886 wiremock::ResponseTemplate::new(200)
887 .set_body_json(serde_json::json!({"results": [{"id": "98765"}]})),
888 )
889 .mount(&server)
890 .await;
891
892 wiremock::Mock::given(wiremock::matchers::method("POST"))
893 .and(wiremock::matchers::path("/wiki/api/v2/pages"))
894 .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Bad Request"))
895 .mount(&server)
896 .await;
897
898 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
899 let api = ConfluenceApi::new(client);
900 let adf = AdfDocument::new();
901 let err = api
902 .create_page("ENG", "Fail", &adf, None)
903 .await
904 .unwrap_err();
905 assert!(err.to_string().contains("400"));
906 }
907
908 #[tokio::test]
909 async fn delete_page_success() {
910 let server = wiremock::MockServer::start().await;
911
912 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
913 .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
914 .respond_with(wiremock::ResponseTemplate::new(204))
915 .expect(1)
916 .mount(&server)
917 .await;
918
919 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
920 let api = ConfluenceApi::new(client);
921 let result = api.delete_page("12345", false).await;
922 assert!(result.is_ok());
923 }
924
925 #[tokio::test]
926 async fn delete_page_with_purge() {
927 let server = wiremock::MockServer::start().await;
928
929 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
930 .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
931 .and(wiremock::matchers::query_param("purge", "true"))
932 .respond_with(wiremock::ResponseTemplate::new(204))
933 .expect(1)
934 .mount(&server)
935 .await;
936
937 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
938 let api = ConfluenceApi::new(client);
939 let result = api.delete_page("12345", true).await;
940 assert!(result.is_ok());
941 }
942
943 #[tokio::test]
944 async fn delete_page_not_found_hints_permissions() {
945 let server = wiremock::MockServer::start().await;
946
947 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
948 .and(wiremock::matchers::path("/wiki/api/v2/pages/99999"))
949 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
950 .expect(1)
951 .mount(&server)
952 .await;
953
954 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
955 let api = ConfluenceApi::new(client);
956 let err = api.delete_page("99999", false).await.unwrap_err();
957 let msg = err.to_string();
958 assert!(msg.contains("not found or insufficient permissions"));
959 assert!(msg.contains("Space Settings"));
960 }
961
962 #[tokio::test]
963 async fn delete_page_forbidden() {
964 let server = wiremock::MockServer::start().await;
965
966 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
967 .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
968 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
969 .expect(1)
970 .mount(&server)
971 .await;
972
973 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
974 let api = ConfluenceApi::new(client);
975 let err = api.delete_page("12345", false).await.unwrap_err();
976 assert!(err.to_string().contains("403"));
977 }
978
979 #[tokio::test]
980 async fn get_children_success() {
981 let server = wiremock::MockServer::start().await;
982
983 wiremock::Mock::given(wiremock::matchers::method("GET"))
984 .and(wiremock::matchers::path(
985 "/wiki/rest/api/content/12345/child/page",
986 ))
987 .respond_with(
988 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
989 "results": [
990 {"id": "111", "title": "Child One"},
991 {"id": "222", "title": "Child Two"}
992 ]
993 })),
994 )
995 .expect(1)
996 .mount(&server)
997 .await;
998
999 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1000 let api = ConfluenceApi::new(client);
1001 let children = api.get_children("12345").await.unwrap();
1002
1003 assert_eq!(children.len(), 2);
1004 assert_eq!(children[0].id, "111");
1005 assert_eq!(children[0].title, "Child One");
1006 assert_eq!(children[1].id, "222");
1007 }
1008
1009 #[tokio::test]
1010 async fn get_children_empty() {
1011 let server = wiremock::MockServer::start().await;
1012
1013 wiremock::Mock::given(wiremock::matchers::method("GET"))
1014 .and(wiremock::matchers::path(
1015 "/wiki/rest/api/content/12345/child/page",
1016 ))
1017 .respond_with(
1018 wiremock::ResponseTemplate::new(200)
1019 .set_body_json(serde_json::json!({"results": []})),
1020 )
1021 .expect(1)
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 children = api.get_children("12345").await.unwrap();
1028 assert!(children.is_empty());
1029 }
1030
1031 #[tokio::test]
1032 async fn get_children_api_error() {
1033 let server = wiremock::MockServer::start().await;
1034
1035 wiremock::Mock::given(wiremock::matchers::method("GET"))
1036 .and(wiremock::matchers::path(
1037 "/wiki/rest/api/content/99999/child/page",
1038 ))
1039 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
1040 .expect(1)
1041 .mount(&server)
1042 .await;
1043
1044 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1045 let api = ConfluenceApi::new(client);
1046 let err = api.get_children("99999").await.unwrap_err();
1047 assert!(err.to_string().contains("404"));
1048 }
1049
1050 #[tokio::test]
1051 async fn resolve_space_key_fallback_on_error() {
1052 let server = wiremock::MockServer::start().await;
1053
1054 wiremock::Mock::given(wiremock::matchers::method("GET"))
1055 .and(wiremock::matchers::path("/wiki/api/v2/spaces/unknown"))
1056 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
1057 .mount(&server)
1058 .await;
1059
1060 let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
1061 let api = ConfluenceApi::new(client);
1062 let key = api.resolve_space_key("unknown").await.unwrap();
1063 assert_eq!(key, "unknown");
1065 }
1066}