1use crate::braze::error::BrazeApiError;
9use crate::braze::BrazeClient;
10use crate::resource::{ContentBlock, ContentBlockState};
11use serde::{Deserialize, Serialize};
12
13const LIST_LIMIT: u32 = 100;
14
15#[derive(Debug, Clone, PartialEq)]
16pub struct ContentBlockSummary {
17 pub content_block_id: String,
18 pub name: String,
19}
20
21impl BrazeClient {
22 pub async fn list_content_blocks(&self) -> Result<Vec<ContentBlockSummary>, BrazeApiError> {
25 let req = self
26 .get(&["content_blocks", "list"])
27 .query(&[("limit", LIST_LIMIT.to_string())]);
28 let resp: ContentBlockListResponse = self.send_json(req).await?;
29 let returned = resp.content_blocks.len();
30
31 let truncation_detail: Option<String> = match resp.count {
44 Some(total) if total > returned => Some(format!("got {returned} of {total} results")),
45 None if returned >= LIST_LIMIT as usize => Some(format!(
46 "got a full page of {returned} result(s) with no total reported; \
47 cannot verify whether more exist"
48 )),
49 _ => None,
50 };
51 if let Some(detail) = truncation_detail {
52 return Err(BrazeApiError::PaginationNotImplemented {
53 endpoint: "/content_blocks/list",
54 detail,
55 });
56 }
57
58 let mut seen: std::collections::HashSet<&str> =
64 std::collections::HashSet::with_capacity(resp.content_blocks.len());
65 for entry in &resp.content_blocks {
66 if !seen.insert(entry.name.as_str()) {
67 return Err(BrazeApiError::DuplicateNameInListResponse {
68 endpoint: "/content_blocks/list",
69 name: entry.name.clone(),
70 });
71 }
72 }
73
74 Ok(resp
75 .content_blocks
76 .into_iter()
77 .map(|w| ContentBlockSummary {
78 content_block_id: w.content_block_id,
79 name: w.name,
80 })
81 .collect())
82 }
83
84 pub async fn get_content_block(&self, id: &str) -> Result<ContentBlock, BrazeApiError> {
93 let req = self
94 .get(&["content_blocks", "info"])
95 .query(&[("content_block_id", id)]);
96 let wire: ContentBlockInfoResponse = self.send_json(req).await?;
97 match wire.classify_message() {
98 InfoMessageClass::Success => {}
99 InfoMessageClass::NotFound => {
100 return Err(BrazeApiError::NotFound {
101 resource: format!("content_block id '{id}'"),
102 });
103 }
104 InfoMessageClass::Unexpected(message) => {
105 return Err(BrazeApiError::UnexpectedApiMessage {
106 endpoint: "/content_blocks/info",
107 message,
108 });
109 }
110 }
111 Ok(ContentBlock {
112 name: wire.name,
113 description: wire.description,
114 content: wire.content,
115 tags: wire.tags,
116 state: ContentBlockState::Active,
120 })
121 }
122
123 pub async fn create_content_block(&self, cb: &ContentBlock) -> Result<String, BrazeApiError> {
124 let body = ContentBlockWriteBody {
125 content_block_id: None,
126 name: &cb.name,
127 description: cb.description.as_deref(),
128 content: &cb.content,
129 tags: &cb.tags,
130 state: Some(cb.state),
134 };
135 let req = self.post(&["content_blocks", "create"]).json(&body);
136 let resp: ContentBlockCreateResponse = self.send_json(req).await?;
137 Ok(resp.content_block_id)
138 }
139
140 pub async fn update_content_block(
154 &self,
155 id: &str,
156 cb: &ContentBlock,
157 ) -> Result<(), BrazeApiError> {
158 let body = ContentBlockWriteBody {
159 content_block_id: Some(id),
160 name: &cb.name,
161 description: cb.description.as_deref(),
162 content: &cb.content,
163 tags: &cb.tags,
164 state: None,
165 };
166 let req = self.post(&["content_blocks", "update"]).json(&body);
167 self.send_ok(req).await
168 }
169}
170
171#[derive(Debug, Deserialize)]
172struct ContentBlockListResponse {
173 #[serde(default)]
174 content_blocks: Vec<ContentBlockListEntry>,
175 #[serde(default)]
176 count: Option<usize>,
177}
178
179#[derive(Debug, Deserialize)]
180struct ContentBlockListEntry {
181 content_block_id: String,
182 name: String,
183}
184
185#[derive(Debug, Deserialize)]
186struct ContentBlockInfoResponse {
187 #[serde(default)]
188 name: String,
189 #[serde(default, skip_serializing_if = "Option::is_none")]
190 description: Option<String>,
191 #[serde(default)]
192 content: String,
193 #[serde(default)]
194 tags: Vec<String>,
195 #[serde(default)]
196 message: Option<String>,
197}
198
199enum InfoMessageClass {
204 Success,
205 NotFound,
206 Unexpected(String),
207}
208
209impl ContentBlockInfoResponse {
210 fn classify_message(&self) -> InfoMessageClass {
211 let Some(raw) = self.message.as_deref() else {
212 return InfoMessageClass::Success;
213 };
214 let trimmed = raw.trim();
215 if trimmed.eq_ignore_ascii_case("success") {
216 return InfoMessageClass::Success;
217 }
218 let lower = trimmed.to_ascii_lowercase();
219 if lower.contains("not found")
224 || lower.contains("no content block")
225 || lower.contains("does not exist")
226 {
227 InfoMessageClass::NotFound
228 } else {
229 InfoMessageClass::Unexpected(raw.to_string())
230 }
231 }
232}
233
234#[derive(Serialize)]
244struct ContentBlockWriteBody<'a> {
245 #[serde(skip_serializing_if = "Option::is_none")]
246 content_block_id: Option<&'a str>,
247 name: &'a str,
248 #[serde(skip_serializing_if = "Option::is_none")]
249 description: Option<&'a str>,
250 content: &'a str,
251 tags: &'a [String],
252 #[serde(skip_serializing_if = "Option::is_none")]
253 state: Option<ContentBlockState>,
254}
255
256#[derive(Debug, Deserialize)]
257struct ContentBlockCreateResponse {
258 content_block_id: String,
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264 use crate::braze::test_client as make_client;
265 use reqwest::StatusCode;
266 use serde_json::json;
267 use wiremock::matchers::{body_json, header, method, path, query_param};
268 use wiremock::{Mock, MockServer, ResponseTemplate};
269
270 #[tokio::test]
271 async fn list_happy_path() {
272 let server = MockServer::start().await;
273 Mock::given(method("GET"))
274 .and(path("/content_blocks/list"))
275 .and(header("authorization", "Bearer test-key"))
276 .and(query_param("limit", "100"))
277 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
278 "count": 2,
279 "content_blocks": [
280 {"content_block_id": "id-1", "name": "promo"},
281 {"content_block_id": "id-2", "name": "header"}
282 ],
283 "message": "success"
284 })))
285 .mount(&server)
286 .await;
287
288 let client = make_client(&server);
289 let summaries = client.list_content_blocks().await.unwrap();
290 assert_eq!(summaries.len(), 2);
291 assert_eq!(summaries[0].content_block_id, "id-1");
292 assert_eq!(summaries[0].name, "promo");
293 }
294
295 #[tokio::test]
296 async fn list_empty_array() {
297 let server = MockServer::start().await;
298 Mock::given(method("GET"))
299 .and(path("/content_blocks/list"))
300 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"content_blocks": []})))
301 .mount(&server)
302 .await;
303 let client = make_client(&server);
304 assert!(client.list_content_blocks().await.unwrap().is_empty());
305 }
306
307 #[tokio::test]
308 async fn list_ignores_unknown_fields() {
309 let server = MockServer::start().await;
310 Mock::given(method("GET"))
311 .and(path("/content_blocks/list"))
312 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
313 "content_blocks": [{
314 "content_block_id": "id-1",
315 "name": "promo",
316 "content_type": "html",
317 "liquid_tag": "{{content_blocks.${promo}}}",
318 "future_metadata": {"foo": "bar"}
319 }]
320 })))
321 .mount(&server)
322 .await;
323 let client = make_client(&server);
324 let summaries = client.list_content_blocks().await.unwrap();
325 assert_eq!(summaries.len(), 1);
326 assert_eq!(summaries[0].name, "promo");
327 }
328
329 #[tokio::test]
330 async fn list_unauthorized() {
331 let server = MockServer::start().await;
332 Mock::given(method("GET"))
333 .and(path("/content_blocks/list"))
334 .respond_with(ResponseTemplate::new(401).set_body_string("invalid"))
335 .mount(&server)
336 .await;
337 let client = make_client(&server);
338 let err = client.list_content_blocks().await.unwrap_err();
339 assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
340 }
341
342 #[tokio::test]
343 async fn info_happy_path() {
344 let server = MockServer::start().await;
345 Mock::given(method("GET"))
346 .and(path("/content_blocks/info"))
347 .and(query_param("content_block_id", "id-1"))
348 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
349 "content_block_id": "id-1",
350 "name": "promo",
351 "description": "Promo banner",
352 "content": "Hello {{ user.${first_name} }}",
353 "tags": ["pr", "dialog"],
354 "content_type": "html",
355 "message": "success"
356 })))
357 .mount(&server)
358 .await;
359
360 let client = make_client(&server);
361 let cb = client.get_content_block("id-1").await.unwrap();
362 assert_eq!(cb.name, "promo");
363 assert_eq!(cb.description.as_deref(), Some("Promo banner"));
364 assert_eq!(cb.content, "Hello {{ user.${first_name} }}");
365 assert_eq!(cb.tags, vec!["pr".to_string(), "dialog".to_string()]);
366 assert_eq!(cb.state, ContentBlockState::Active);
368 }
369
370 #[tokio::test]
371 async fn info_with_unrecognised_error_message_surfaces_as_unexpected() {
372 let server = MockServer::start().await;
378 Mock::given(method("GET"))
379 .and(path("/content_blocks/info"))
380 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
381 "message": "Internal server hiccup, please retry"
382 })))
383 .mount(&server)
384 .await;
385 let client = make_client(&server);
386 let err = client.get_content_block("some-id").await.unwrap_err();
387 match err {
388 BrazeApiError::UnexpectedApiMessage { endpoint, message } => {
389 assert_eq!(endpoint, "/content_blocks/info");
390 assert!(
391 message.contains("Internal server hiccup"),
392 "message not preserved verbatim: {message}"
393 );
394 }
395 other => panic!("expected UnexpectedApiMessage, got {other:?}"),
396 }
397 }
398
399 #[tokio::test]
400 async fn info_with_unsuccessful_message_is_not_found() {
401 let server = MockServer::start().await;
402 Mock::given(method("GET"))
403 .and(path("/content_blocks/info"))
404 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
405 "message": "No content block with id 'missing' found"
406 })))
407 .mount(&server)
408 .await;
409 let client = make_client(&server);
410 let err = client.get_content_block("missing").await.unwrap_err();
411 match err {
412 BrazeApiError::NotFound { resource } => assert!(resource.contains("missing")),
413 other => panic!("expected NotFound, got {other:?}"),
414 }
415 }
416
417 #[tokio::test]
418 async fn create_sends_correct_body_and_returns_id() {
419 let server = MockServer::start().await;
420 Mock::given(method("POST"))
421 .and(path("/content_blocks/create"))
422 .and(header("authorization", "Bearer test-key"))
423 .and(body_json(json!({
424 "name": "promo",
425 "description": "Promo banner",
426 "content": "Hello",
427 "tags": ["pr"],
428 "state": "active"
429 })))
430 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
431 "content_block_id": "new-id-123",
432 "message": "success"
433 })))
434 .mount(&server)
435 .await;
436
437 let client = make_client(&server);
438 let cb = ContentBlock {
439 name: "promo".into(),
440 description: Some("Promo banner".into()),
441 content: "Hello".into(),
442 tags: vec!["pr".into()],
443 state: ContentBlockState::Active,
444 };
445 let id = client.create_content_block(&cb).await.unwrap();
446 assert_eq!(id, "new-id-123");
447 }
448
449 #[tokio::test]
450 async fn create_omits_description_when_none() {
451 let server = MockServer::start().await;
452 Mock::given(method("POST"))
453 .and(path("/content_blocks/create"))
454 .and(body_json(json!({
455 "name": "minimal",
456 "content": "x",
457 "tags": [],
458 "state": "active"
459 })))
460 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
461 "content_block_id": "id-min"
462 })))
463 .mount(&server)
464 .await;
465 let client = make_client(&server);
466 let cb = ContentBlock {
467 name: "minimal".into(),
468 description: None,
469 content: "x".into(),
470 tags: vec![],
471 state: ContentBlockState::Active,
472 };
473 client.create_content_block(&cb).await.unwrap();
474 }
475
476 #[tokio::test]
477 async fn create_forwards_draft_state_to_request_body() {
478 let server = MockServer::start().await;
483 Mock::given(method("POST"))
484 .and(path("/content_blocks/create"))
485 .and(body_json(json!({
486 "name": "wip",
487 "content": "draft body",
488 "tags": [],
489 "state": "draft"
490 })))
491 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
492 "content_block_id": "id-wip"
493 })))
494 .expect(1)
495 .mount(&server)
496 .await;
497 let client = make_client(&server);
498 let cb = ContentBlock {
499 name: "wip".into(),
500 description: None,
501 content: "draft body".into(),
502 tags: vec![],
503 state: ContentBlockState::Draft,
504 };
505 client.create_content_block(&cb).await.unwrap();
506 }
507
508 #[tokio::test]
509 async fn update_sends_id_in_body_and_omits_state() {
510 let server = MockServer::start().await;
518 Mock::given(method("POST"))
519 .and(path("/content_blocks/update"))
520 .and(body_json(json!({
521 "content_block_id": "id-1",
522 "name": "promo",
523 "content": "Updated body",
524 "tags": []
525 })))
526 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"message": "success"})))
527 .mount(&server)
528 .await;
529
530 let client = make_client(&server);
531 let cb = ContentBlock {
535 name: "promo".into(),
536 description: None,
537 content: "Updated body".into(),
538 tags: vec![],
539 state: ContentBlockState::Draft,
540 };
541 client.update_content_block("id-1", &cb).await.unwrap();
542 }
543
544 #[tokio::test]
545 async fn update_unauthorized_propagates() {
546 let server = MockServer::start().await;
547 Mock::given(method("POST"))
548 .and(path("/content_blocks/update"))
549 .respond_with(ResponseTemplate::new(401).set_body_string("invalid"))
550 .mount(&server)
551 .await;
552 let client = make_client(&server);
553 let cb = ContentBlock {
554 name: "x".into(),
555 description: None,
556 content: String::new(),
557 tags: vec![],
558 state: ContentBlockState::Active,
559 };
560 let err = client.update_content_block("id", &cb).await.unwrap_err();
561 assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
562 }
563
564 #[tokio::test]
565 async fn update_server_error_is_http() {
566 let server = MockServer::start().await;
567 Mock::given(method("POST"))
568 .and(path("/content_blocks/update"))
569 .respond_with(ResponseTemplate::new(500).set_body_string("oops"))
570 .mount(&server)
571 .await;
572 let client = make_client(&server);
573 let cb = ContentBlock {
574 name: "x".into(),
575 description: None,
576 content: String::new(),
577 tags: vec![],
578 state: ContentBlockState::Active,
579 };
580 let err = client.update_content_block("id", &cb).await.unwrap_err();
581 match err {
582 BrazeApiError::Http { status, body } => {
583 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
584 assert!(body.contains("oops"));
585 }
586 other => panic!("expected Http, got {other:?}"),
587 }
588 }
589
590 #[tokio::test]
591 async fn list_errors_when_count_exceeds_returned() {
592 let server = MockServer::start().await;
593 let entries: Vec<serde_json::Value> = (0..100)
594 .map(|i| {
595 json!({
596 "content_block_id": format!("id-{i}"),
597 "name": format!("block-{i}")
598 })
599 })
600 .collect();
601 Mock::given(method("GET"))
602 .and(path("/content_blocks/list"))
603 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
604 "count": 250,
605 "content_blocks": entries,
606 "message": "success"
607 })))
608 .mount(&server)
609 .await;
610 let client = make_client(&server);
611 let err = client.list_content_blocks().await.unwrap_err();
612 match err {
613 BrazeApiError::PaginationNotImplemented { endpoint, detail } => {
614 assert_eq!(endpoint, "/content_blocks/list");
615 assert!(detail.contains("100"), "detail: {detail}");
616 assert!(detail.contains("250"), "detail: {detail}");
617 }
618 other => panic!("expected PaginationNotImplemented, got {other:?}"),
619 }
620 }
621
622 #[tokio::test]
623 async fn list_errors_on_full_page_with_no_count_field() {
624 let server = MockServer::start().await;
627 let entries: Vec<serde_json::Value> = (0..100)
628 .map(|i| {
629 json!({
630 "content_block_id": format!("id-{i}"),
631 "name": format!("block-{i}")
632 })
633 })
634 .collect();
635 Mock::given(method("GET"))
636 .and(path("/content_blocks/list"))
637 .respond_with(
638 ResponseTemplate::new(200).set_body_json(json!({ "content_blocks": entries })),
639 )
640 .mount(&server)
641 .await;
642 let client = make_client(&server);
643 let err = client.list_content_blocks().await.unwrap_err();
644 assert!(
645 matches!(err, BrazeApiError::PaginationNotImplemented { .. }),
646 "got {err:?}"
647 );
648 }
649
650 #[tokio::test]
651 async fn list_short_page_with_no_count_is_trusted_as_complete() {
652 let server = MockServer::start().await;
660 Mock::given(method("GET"))
661 .and(path("/content_blocks/list"))
662 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
663 "content_blocks": [
664 {"content_block_id": "id-1", "name": "a"},
665 {"content_block_id": "id-2", "name": "b"}
666 ]
667 })))
668 .mount(&server)
669 .await;
670 let client = make_client(&server);
671 let summaries = client.list_content_blocks().await.unwrap();
672 assert_eq!(summaries.len(), 2);
673 assert_eq!(summaries[0].name, "a");
674 assert_eq!(summaries[1].name, "b");
675 }
676
677 #[tokio::test]
678 async fn list_succeeds_when_count_matches_full_page_exactly() {
679 let server = MockServer::start().await;
682 let entries: Vec<serde_json::Value> = (0..100)
683 .map(|i| {
684 json!({
685 "content_block_id": format!("id-{i}"),
686 "name": format!("block-{i}")
687 })
688 })
689 .collect();
690 Mock::given(method("GET"))
691 .and(path("/content_blocks/list"))
692 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
693 "count": 100,
694 "content_blocks": entries
695 })))
696 .mount(&server)
697 .await;
698 let client = make_client(&server);
699 let summaries = client.list_content_blocks().await.unwrap();
700 assert_eq!(summaries.len(), 100);
701 }
702
703 #[tokio::test]
704 async fn list_errors_on_duplicate_name_in_response() {
705 let server = MockServer::start().await;
711 Mock::given(method("GET"))
712 .and(path("/content_blocks/list"))
713 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
714 "count": 2,
715 "content_blocks": [
716 {"content_block_id": "id-a", "name": "dup"},
717 {"content_block_id": "id-b", "name": "dup"}
718 ]
719 })))
720 .mount(&server)
721 .await;
722 let client = make_client(&server);
723 let err = client.list_content_blocks().await.unwrap_err();
724 match err {
725 BrazeApiError::DuplicateNameInListResponse { endpoint, name } => {
726 assert_eq!(endpoint, "/content_blocks/list");
727 assert_eq!(name, "dup");
728 }
729 other => panic!("expected DuplicateNameInListResponse, got {other:?}"),
730 }
731 }
732
733 #[tokio::test]
734 async fn list_retries_on_429_then_succeeds() {
735 let server = MockServer::start().await;
736 Mock::given(method("GET"))
737 .and(path("/content_blocks/list"))
738 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
739 "content_blocks": [{"content_block_id": "id-x", "name": "x"}]
740 })))
741 .mount(&server)
742 .await;
743 Mock::given(method("GET"))
744 .and(path("/content_blocks/list"))
745 .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
746 .up_to_n_times(1)
747 .mount(&server)
748 .await;
749 let client = make_client(&server);
750 let summaries = client.list_content_blocks().await.unwrap();
751 assert_eq!(summaries.len(), 1);
752 assert_eq!(summaries[0].name, "x");
753 }
754}