1use crate::braze::error::BrazeApiError;
9use crate::braze::{classify_info_message, BrazeClient, InfoMessageClass};
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 classify_info_message(wire.message.as_deref(), "no content block") {
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
199#[derive(Serialize)]
209struct ContentBlockWriteBody<'a> {
210 #[serde(skip_serializing_if = "Option::is_none")]
211 content_block_id: Option<&'a str>,
212 name: &'a str,
213 #[serde(skip_serializing_if = "Option::is_none")]
214 description: Option<&'a str>,
215 content: &'a str,
216 tags: &'a [String],
217 #[serde(skip_serializing_if = "Option::is_none")]
218 state: Option<ContentBlockState>,
219}
220
221#[derive(Debug, Deserialize)]
222struct ContentBlockCreateResponse {
223 content_block_id: String,
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229 use crate::braze::test_client as make_client;
230 use reqwest::StatusCode;
231 use serde_json::json;
232 use wiremock::matchers::{body_json, header, method, path, query_param};
233 use wiremock::{Mock, MockServer, ResponseTemplate};
234
235 #[tokio::test]
236 async fn list_happy_path() {
237 let server = MockServer::start().await;
238 Mock::given(method("GET"))
239 .and(path("/content_blocks/list"))
240 .and(header("authorization", "Bearer test-key"))
241 .and(query_param("limit", "100"))
242 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
243 "count": 2,
244 "content_blocks": [
245 {"content_block_id": "id-1", "name": "promo"},
246 {"content_block_id": "id-2", "name": "header"}
247 ],
248 "message": "success"
249 })))
250 .mount(&server)
251 .await;
252
253 let client = make_client(&server);
254 let summaries = client.list_content_blocks().await.unwrap();
255 assert_eq!(summaries.len(), 2);
256 assert_eq!(summaries[0].content_block_id, "id-1");
257 assert_eq!(summaries[0].name, "promo");
258 }
259
260 #[tokio::test]
261 async fn list_empty_array() {
262 let server = MockServer::start().await;
263 Mock::given(method("GET"))
264 .and(path("/content_blocks/list"))
265 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"content_blocks": []})))
266 .mount(&server)
267 .await;
268 let client = make_client(&server);
269 assert!(client.list_content_blocks().await.unwrap().is_empty());
270 }
271
272 #[tokio::test]
273 async fn list_ignores_unknown_fields() {
274 let server = MockServer::start().await;
275 Mock::given(method("GET"))
276 .and(path("/content_blocks/list"))
277 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
278 "content_blocks": [{
279 "content_block_id": "id-1",
280 "name": "promo",
281 "content_type": "html",
282 "liquid_tag": "{{content_blocks.${promo}}}",
283 "future_metadata": {"foo": "bar"}
284 }]
285 })))
286 .mount(&server)
287 .await;
288 let client = make_client(&server);
289 let summaries = client.list_content_blocks().await.unwrap();
290 assert_eq!(summaries.len(), 1);
291 assert_eq!(summaries[0].name, "promo");
292 }
293
294 #[tokio::test]
295 async fn list_unauthorized() {
296 let server = MockServer::start().await;
297 Mock::given(method("GET"))
298 .and(path("/content_blocks/list"))
299 .respond_with(ResponseTemplate::new(401).set_body_string("invalid"))
300 .mount(&server)
301 .await;
302 let client = make_client(&server);
303 let err = client.list_content_blocks().await.unwrap_err();
304 assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
305 }
306
307 #[tokio::test]
308 async fn info_happy_path() {
309 let server = MockServer::start().await;
310 Mock::given(method("GET"))
311 .and(path("/content_blocks/info"))
312 .and(query_param("content_block_id", "id-1"))
313 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
314 "content_block_id": "id-1",
315 "name": "promo",
316 "description": "Promo banner",
317 "content": "Hello {{ user.${first_name} }}",
318 "tags": ["pr", "dialog"],
319 "content_type": "html",
320 "message": "success"
321 })))
322 .mount(&server)
323 .await;
324
325 let client = make_client(&server);
326 let cb = client.get_content_block("id-1").await.unwrap();
327 assert_eq!(cb.name, "promo");
328 assert_eq!(cb.description.as_deref(), Some("Promo banner"));
329 assert_eq!(cb.content, "Hello {{ user.${first_name} }}");
330 assert_eq!(cb.tags, vec!["pr".to_string(), "dialog".to_string()]);
331 assert_eq!(cb.state, ContentBlockState::Active);
333 }
334
335 #[tokio::test]
336 async fn info_with_unrecognised_error_message_surfaces_as_unexpected() {
337 let server = MockServer::start().await;
343 Mock::given(method("GET"))
344 .and(path("/content_blocks/info"))
345 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
346 "message": "Internal server hiccup, please retry"
347 })))
348 .mount(&server)
349 .await;
350 let client = make_client(&server);
351 let err = client.get_content_block("some-id").await.unwrap_err();
352 match err {
353 BrazeApiError::UnexpectedApiMessage { endpoint, message } => {
354 assert_eq!(endpoint, "/content_blocks/info");
355 assert!(
356 message.contains("Internal server hiccup"),
357 "message not preserved verbatim: {message}"
358 );
359 }
360 other => panic!("expected UnexpectedApiMessage, got {other:?}"),
361 }
362 }
363
364 #[tokio::test]
365 async fn info_with_unsuccessful_message_is_not_found() {
366 let server = MockServer::start().await;
367 Mock::given(method("GET"))
368 .and(path("/content_blocks/info"))
369 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
370 "message": "No content block with id 'missing' found"
371 })))
372 .mount(&server)
373 .await;
374 let client = make_client(&server);
375 let err = client.get_content_block("missing").await.unwrap_err();
376 match err {
377 BrazeApiError::NotFound { resource } => assert!(resource.contains("missing")),
378 other => panic!("expected NotFound, got {other:?}"),
379 }
380 }
381
382 #[tokio::test]
383 async fn create_sends_correct_body_and_returns_id() {
384 let server = MockServer::start().await;
385 Mock::given(method("POST"))
386 .and(path("/content_blocks/create"))
387 .and(header("authorization", "Bearer test-key"))
388 .and(body_json(json!({
389 "name": "promo",
390 "description": "Promo banner",
391 "content": "Hello",
392 "tags": ["pr"],
393 "state": "active"
394 })))
395 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
396 "content_block_id": "new-id-123",
397 "message": "success"
398 })))
399 .mount(&server)
400 .await;
401
402 let client = make_client(&server);
403 let cb = ContentBlock {
404 name: "promo".into(),
405 description: Some("Promo banner".into()),
406 content: "Hello".into(),
407 tags: vec!["pr".into()],
408 state: ContentBlockState::Active,
409 };
410 let id = client.create_content_block(&cb).await.unwrap();
411 assert_eq!(id, "new-id-123");
412 }
413
414 #[tokio::test]
415 async fn create_omits_description_when_none() {
416 let server = MockServer::start().await;
417 Mock::given(method("POST"))
418 .and(path("/content_blocks/create"))
419 .and(body_json(json!({
420 "name": "minimal",
421 "content": "x",
422 "tags": [],
423 "state": "active"
424 })))
425 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
426 "content_block_id": "id-min"
427 })))
428 .mount(&server)
429 .await;
430 let client = make_client(&server);
431 let cb = ContentBlock {
432 name: "minimal".into(),
433 description: None,
434 content: "x".into(),
435 tags: vec![],
436 state: ContentBlockState::Active,
437 };
438 client.create_content_block(&cb).await.unwrap();
439 }
440
441 #[tokio::test]
442 async fn create_forwards_draft_state_to_request_body() {
443 let server = MockServer::start().await;
448 Mock::given(method("POST"))
449 .and(path("/content_blocks/create"))
450 .and(body_json(json!({
451 "name": "wip",
452 "content": "draft body",
453 "tags": [],
454 "state": "draft"
455 })))
456 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
457 "content_block_id": "id-wip"
458 })))
459 .expect(1)
460 .mount(&server)
461 .await;
462 let client = make_client(&server);
463 let cb = ContentBlock {
464 name: "wip".into(),
465 description: None,
466 content: "draft body".into(),
467 tags: vec![],
468 state: ContentBlockState::Draft,
469 };
470 client.create_content_block(&cb).await.unwrap();
471 }
472
473 #[tokio::test]
474 async fn update_sends_id_in_body_and_omits_state() {
475 let server = MockServer::start().await;
483 Mock::given(method("POST"))
484 .and(path("/content_blocks/update"))
485 .and(body_json(json!({
486 "content_block_id": "id-1",
487 "name": "promo",
488 "content": "Updated body",
489 "tags": []
490 })))
491 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"message": "success"})))
492 .mount(&server)
493 .await;
494
495 let client = make_client(&server);
496 let cb = ContentBlock {
500 name: "promo".into(),
501 description: None,
502 content: "Updated body".into(),
503 tags: vec![],
504 state: ContentBlockState::Draft,
505 };
506 client.update_content_block("id-1", &cb).await.unwrap();
507 }
508
509 #[tokio::test]
510 async fn update_unauthorized_propagates() {
511 let server = MockServer::start().await;
512 Mock::given(method("POST"))
513 .and(path("/content_blocks/update"))
514 .respond_with(ResponseTemplate::new(401).set_body_string("invalid"))
515 .mount(&server)
516 .await;
517 let client = make_client(&server);
518 let cb = ContentBlock {
519 name: "x".into(),
520 description: None,
521 content: String::new(),
522 tags: vec![],
523 state: ContentBlockState::Active,
524 };
525 let err = client.update_content_block("id", &cb).await.unwrap_err();
526 assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
527 }
528
529 #[tokio::test]
530 async fn update_server_error_is_http() {
531 let server = MockServer::start().await;
532 Mock::given(method("POST"))
533 .and(path("/content_blocks/update"))
534 .respond_with(ResponseTemplate::new(500).set_body_string("oops"))
535 .mount(&server)
536 .await;
537 let client = make_client(&server);
538 let cb = ContentBlock {
539 name: "x".into(),
540 description: None,
541 content: String::new(),
542 tags: vec![],
543 state: ContentBlockState::Active,
544 };
545 let err = client.update_content_block("id", &cb).await.unwrap_err();
546 match err {
547 BrazeApiError::Http { status, body } => {
548 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
549 assert!(body.contains("oops"));
550 }
551 other => panic!("expected Http, got {other:?}"),
552 }
553 }
554
555 #[tokio::test]
556 async fn list_errors_when_count_exceeds_returned() {
557 let server = MockServer::start().await;
558 let entries: Vec<serde_json::Value> = (0..100)
559 .map(|i| {
560 json!({
561 "content_block_id": format!("id-{i}"),
562 "name": format!("block-{i}")
563 })
564 })
565 .collect();
566 Mock::given(method("GET"))
567 .and(path("/content_blocks/list"))
568 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
569 "count": 250,
570 "content_blocks": entries,
571 "message": "success"
572 })))
573 .mount(&server)
574 .await;
575 let client = make_client(&server);
576 let err = client.list_content_blocks().await.unwrap_err();
577 match err {
578 BrazeApiError::PaginationNotImplemented { endpoint, detail } => {
579 assert_eq!(endpoint, "/content_blocks/list");
580 assert!(detail.contains("100"), "detail: {detail}");
581 assert!(detail.contains("250"), "detail: {detail}");
582 }
583 other => panic!("expected PaginationNotImplemented, got {other:?}"),
584 }
585 }
586
587 #[tokio::test]
588 async fn list_errors_on_full_page_with_no_count_field() {
589 let server = MockServer::start().await;
592 let entries: Vec<serde_json::Value> = (0..100)
593 .map(|i| {
594 json!({
595 "content_block_id": format!("id-{i}"),
596 "name": format!("block-{i}")
597 })
598 })
599 .collect();
600 Mock::given(method("GET"))
601 .and(path("/content_blocks/list"))
602 .respond_with(
603 ResponseTemplate::new(200).set_body_json(json!({ "content_blocks": entries })),
604 )
605 .mount(&server)
606 .await;
607 let client = make_client(&server);
608 let err = client.list_content_blocks().await.unwrap_err();
609 assert!(
610 matches!(err, BrazeApiError::PaginationNotImplemented { .. }),
611 "got {err:?}"
612 );
613 }
614
615 #[tokio::test]
616 async fn list_short_page_with_no_count_is_trusted_as_complete() {
617 let server = MockServer::start().await;
625 Mock::given(method("GET"))
626 .and(path("/content_blocks/list"))
627 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
628 "content_blocks": [
629 {"content_block_id": "id-1", "name": "a"},
630 {"content_block_id": "id-2", "name": "b"}
631 ]
632 })))
633 .mount(&server)
634 .await;
635 let client = make_client(&server);
636 let summaries = client.list_content_blocks().await.unwrap();
637 assert_eq!(summaries.len(), 2);
638 assert_eq!(summaries[0].name, "a");
639 assert_eq!(summaries[1].name, "b");
640 }
641
642 #[tokio::test]
643 async fn list_succeeds_when_count_matches_full_page_exactly() {
644 let server = MockServer::start().await;
647 let entries: Vec<serde_json::Value> = (0..100)
648 .map(|i| {
649 json!({
650 "content_block_id": format!("id-{i}"),
651 "name": format!("block-{i}")
652 })
653 })
654 .collect();
655 Mock::given(method("GET"))
656 .and(path("/content_blocks/list"))
657 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
658 "count": 100,
659 "content_blocks": entries
660 })))
661 .mount(&server)
662 .await;
663 let client = make_client(&server);
664 let summaries = client.list_content_blocks().await.unwrap();
665 assert_eq!(summaries.len(), 100);
666 }
667
668 #[tokio::test]
669 async fn list_errors_on_duplicate_name_in_response() {
670 let server = MockServer::start().await;
676 Mock::given(method("GET"))
677 .and(path("/content_blocks/list"))
678 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
679 "count": 2,
680 "content_blocks": [
681 {"content_block_id": "id-a", "name": "dup"},
682 {"content_block_id": "id-b", "name": "dup"}
683 ]
684 })))
685 .mount(&server)
686 .await;
687 let client = make_client(&server);
688 let err = client.list_content_blocks().await.unwrap_err();
689 match err {
690 BrazeApiError::DuplicateNameInListResponse { endpoint, name } => {
691 assert_eq!(endpoint, "/content_blocks/list");
692 assert_eq!(name, "dup");
693 }
694 other => panic!("expected DuplicateNameInListResponse, got {other:?}"),
695 }
696 }
697
698 #[tokio::test]
699 async fn list_retries_on_429_then_succeeds() {
700 let server = MockServer::start().await;
701 Mock::given(method("GET"))
702 .and(path("/content_blocks/list"))
703 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
704 "content_blocks": [{"content_block_id": "id-x", "name": "x"}]
705 })))
706 .mount(&server)
707 .await;
708 Mock::given(method("GET"))
709 .and(path("/content_blocks/list"))
710 .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
711 .up_to_n_times(1)
712 .mount(&server)
713 .await;
714 let client = make_client(&server);
715 let summaries = client.list_content_blocks().await.unwrap();
716 assert_eq!(summaries.len(), 1);
717 assert_eq!(summaries[0].name, "x");
718 }
719}