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