1use crate::braze::error::BrazeApiError;
9use crate::braze::{
10 check_duplicate_names, check_pagination, classify_info_message, BrazeClient, InfoMessageClass,
11};
12use crate::resource::{ContentBlock, ContentBlockState};
13use serde::{Deserialize, Serialize};
14
15const LIST_LIMIT: u32 = 100;
16
17#[derive(Debug, Clone, PartialEq)]
18pub struct ContentBlockSummary {
19 pub content_block_id: String,
20 pub name: String,
21}
22
23impl BrazeClient {
24 pub async fn list_content_blocks(&self) -> Result<Vec<ContentBlockSummary>, BrazeApiError> {
27 let req = self
28 .get(&["content_blocks", "list"])
29 .query(&[("limit", LIST_LIMIT.to_string())]);
30 let resp: ContentBlockListResponse = self.send_json(req).await?;
31 let returned = resp.content_blocks.len();
32
33 check_pagination(
35 resp.count,
36 returned,
37 LIST_LIMIT as usize,
38 "/content_blocks/list",
39 )?;
40
41 check_duplicate_names(
42 resp.content_blocks.iter().map(|e| e.name.as_str()),
43 resp.content_blocks.len(),
44 "/content_blocks/list",
45 )?;
46
47 Ok(resp
48 .content_blocks
49 .into_iter()
50 .map(|w| ContentBlockSummary {
51 content_block_id: w.content_block_id,
52 name: w.name,
53 })
54 .collect())
55 }
56
57 pub async fn get_content_block(&self, id: &str) -> Result<ContentBlock, BrazeApiError> {
66 let req = self
67 .get(&["content_blocks", "info"])
68 .query(&[("content_block_id", id)]);
69 let wire: ContentBlockInfoResponse = self.send_json(req).await?;
70 match classify_info_message(wire.message.as_deref(), "no content block") {
71 InfoMessageClass::Success => {}
72 InfoMessageClass::NotFound => {
73 return Err(BrazeApiError::NotFound {
74 resource: format!("content_block id '{id}'"),
75 });
76 }
77 InfoMessageClass::Unexpected(message) => {
78 return Err(BrazeApiError::UnexpectedApiMessage {
79 endpoint: "/content_blocks/info",
80 message,
81 });
82 }
83 }
84 Ok(ContentBlock {
85 name: wire.name,
86 description: wire.description,
87 content: wire.content,
88 tags: wire.tags,
89 state: ContentBlockState::Active,
93 })
94 }
95
96 pub async fn create_content_block(&self, cb: &ContentBlock) -> Result<String, BrazeApiError> {
97 let body = ContentBlockWriteBody {
98 content_block_id: None,
99 name: &cb.name,
100 description: cb.description.as_deref(),
101 content: &cb.content,
102 tags: &cb.tags,
103 state: Some(cb.state),
107 };
108 let req = self.post(&["content_blocks", "create"]).json(&body);
109 let resp: ContentBlockCreateResponse = self.send_json(req).await?;
110 Ok(resp.content_block_id)
111 }
112
113 pub async fn update_content_block(
127 &self,
128 id: &str,
129 cb: &ContentBlock,
130 ) -> Result<(), BrazeApiError> {
131 let body = ContentBlockWriteBody {
132 content_block_id: Some(id),
133 name: &cb.name,
134 description: cb.description.as_deref(),
135 content: &cb.content,
136 tags: &cb.tags,
137 state: None,
138 };
139 let req = self.post(&["content_blocks", "update"]).json(&body);
140 self.send_ok(req).await
141 }
142}
143
144#[derive(Debug, Deserialize)]
145struct ContentBlockListResponse {
146 #[serde(default)]
147 content_blocks: Vec<ContentBlockListEntry>,
148 #[serde(default)]
149 count: Option<usize>,
150}
151
152#[derive(Debug, Deserialize)]
153struct ContentBlockListEntry {
154 content_block_id: String,
155 name: String,
156}
157
158#[derive(Debug, Deserialize)]
159struct ContentBlockInfoResponse {
160 #[serde(default)]
161 name: String,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
163 description: Option<String>,
164 #[serde(default)]
165 content: String,
166 #[serde(default)]
167 tags: Vec<String>,
168 #[serde(default)]
169 message: Option<String>,
170}
171
172#[derive(Serialize)]
182struct ContentBlockWriteBody<'a> {
183 #[serde(skip_serializing_if = "Option::is_none")]
184 content_block_id: Option<&'a str>,
185 name: &'a str,
186 #[serde(skip_serializing_if = "Option::is_none")]
187 description: Option<&'a str>,
188 content: &'a str,
189 tags: &'a [String],
190 #[serde(skip_serializing_if = "Option::is_none")]
191 state: Option<ContentBlockState>,
192}
193
194#[derive(Debug, Deserialize)]
195struct ContentBlockCreateResponse {
196 content_block_id: String,
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202 use crate::braze::test_client as make_client;
203 use reqwest::StatusCode;
204 use serde_json::json;
205 use wiremock::matchers::{body_json, header, method, path, query_param};
206 use wiremock::{Mock, MockServer, ResponseTemplate};
207
208 #[tokio::test]
209 async fn list_happy_path() {
210 let server = MockServer::start().await;
211 Mock::given(method("GET"))
212 .and(path("/content_blocks/list"))
213 .and(header("authorization", "Bearer test-key"))
214 .and(query_param("limit", "100"))
215 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
216 "count": 2,
217 "content_blocks": [
218 {"content_block_id": "id-1", "name": "promo"},
219 {"content_block_id": "id-2", "name": "header"}
220 ],
221 "message": "success"
222 })))
223 .mount(&server)
224 .await;
225
226 let client = make_client(&server);
227 let summaries = client.list_content_blocks().await.unwrap();
228 assert_eq!(summaries.len(), 2);
229 assert_eq!(summaries[0].content_block_id, "id-1");
230 assert_eq!(summaries[0].name, "promo");
231 }
232
233 #[tokio::test]
234 async fn list_empty_array() {
235 let server = MockServer::start().await;
236 Mock::given(method("GET"))
237 .and(path("/content_blocks/list"))
238 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"content_blocks": []})))
239 .mount(&server)
240 .await;
241 let client = make_client(&server);
242 assert!(client.list_content_blocks().await.unwrap().is_empty());
243 }
244
245 #[tokio::test]
246 async fn list_ignores_unknown_fields() {
247 let server = MockServer::start().await;
248 Mock::given(method("GET"))
249 .and(path("/content_blocks/list"))
250 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
251 "content_blocks": [{
252 "content_block_id": "id-1",
253 "name": "promo",
254 "content_type": "html",
255 "liquid_tag": "{{content_blocks.${promo}}}",
256 "future_metadata": {"foo": "bar"}
257 }]
258 })))
259 .mount(&server)
260 .await;
261 let client = make_client(&server);
262 let summaries = client.list_content_blocks().await.unwrap();
263 assert_eq!(summaries.len(), 1);
264 assert_eq!(summaries[0].name, "promo");
265 }
266
267 #[tokio::test]
268 async fn list_unauthorized() {
269 let server = MockServer::start().await;
270 Mock::given(method("GET"))
271 .and(path("/content_blocks/list"))
272 .respond_with(ResponseTemplate::new(401).set_body_string("invalid"))
273 .mount(&server)
274 .await;
275 let client = make_client(&server);
276 let err = client.list_content_blocks().await.unwrap_err();
277 assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
278 }
279
280 #[tokio::test]
281 async fn info_happy_path() {
282 let server = MockServer::start().await;
283 Mock::given(method("GET"))
284 .and(path("/content_blocks/info"))
285 .and(query_param("content_block_id", "id-1"))
286 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
287 "content_block_id": "id-1",
288 "name": "promo",
289 "description": "Promo banner",
290 "content": "Hello {{ user.${first_name} }}",
291 "tags": ["pr", "dialog"],
292 "content_type": "html",
293 "message": "success"
294 })))
295 .mount(&server)
296 .await;
297
298 let client = make_client(&server);
299 let cb = client.get_content_block("id-1").await.unwrap();
300 assert_eq!(cb.name, "promo");
301 assert_eq!(cb.description.as_deref(), Some("Promo banner"));
302 assert_eq!(cb.content, "Hello {{ user.${first_name} }}");
303 assert_eq!(cb.tags, vec!["pr".to_string(), "dialog".to_string()]);
304 assert_eq!(cb.state, ContentBlockState::Active);
306 }
307
308 #[tokio::test]
309 async fn info_with_unrecognised_error_message_surfaces_as_unexpected() {
310 let server = MockServer::start().await;
316 Mock::given(method("GET"))
317 .and(path("/content_blocks/info"))
318 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
319 "message": "Internal server hiccup, please retry"
320 })))
321 .mount(&server)
322 .await;
323 let client = make_client(&server);
324 let err = client.get_content_block("some-id").await.unwrap_err();
325 match err {
326 BrazeApiError::UnexpectedApiMessage { endpoint, message } => {
327 assert_eq!(endpoint, "/content_blocks/info");
328 assert!(
329 message.contains("Internal server hiccup"),
330 "message not preserved verbatim: {message}"
331 );
332 }
333 other => panic!("expected UnexpectedApiMessage, got {other:?}"),
334 }
335 }
336
337 #[tokio::test]
338 async fn info_with_unsuccessful_message_is_not_found() {
339 let server = MockServer::start().await;
340 Mock::given(method("GET"))
341 .and(path("/content_blocks/info"))
342 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
343 "message": "No content block with id 'missing' found"
344 })))
345 .mount(&server)
346 .await;
347 let client = make_client(&server);
348 let err = client.get_content_block("missing").await.unwrap_err();
349 match err {
350 BrazeApiError::NotFound { resource } => assert!(resource.contains("missing")),
351 other => panic!("expected NotFound, got {other:?}"),
352 }
353 }
354
355 #[tokio::test]
356 async fn create_sends_correct_body_and_returns_id() {
357 let server = MockServer::start().await;
358 Mock::given(method("POST"))
359 .and(path("/content_blocks/create"))
360 .and(header("authorization", "Bearer test-key"))
361 .and(body_json(json!({
362 "name": "promo",
363 "description": "Promo banner",
364 "content": "Hello",
365 "tags": ["pr"],
366 "state": "active"
367 })))
368 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
369 "content_block_id": "new-id-123",
370 "message": "success"
371 })))
372 .mount(&server)
373 .await;
374
375 let client = make_client(&server);
376 let cb = ContentBlock {
377 name: "promo".into(),
378 description: Some("Promo banner".into()),
379 content: "Hello".into(),
380 tags: vec!["pr".into()],
381 state: ContentBlockState::Active,
382 };
383 let id = client.create_content_block(&cb).await.unwrap();
384 assert_eq!(id, "new-id-123");
385 }
386
387 #[tokio::test]
388 async fn create_omits_description_when_none() {
389 let server = MockServer::start().await;
390 Mock::given(method("POST"))
391 .and(path("/content_blocks/create"))
392 .and(body_json(json!({
393 "name": "minimal",
394 "content": "x",
395 "tags": [],
396 "state": "active"
397 })))
398 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
399 "content_block_id": "id-min"
400 })))
401 .mount(&server)
402 .await;
403 let client = make_client(&server);
404 let cb = ContentBlock {
405 name: "minimal".into(),
406 description: None,
407 content: "x".into(),
408 tags: vec![],
409 state: ContentBlockState::Active,
410 };
411 client.create_content_block(&cb).await.unwrap();
412 }
413
414 #[tokio::test]
415 async fn create_forwards_draft_state_to_request_body() {
416 let server = MockServer::start().await;
421 Mock::given(method("POST"))
422 .and(path("/content_blocks/create"))
423 .and(body_json(json!({
424 "name": "wip",
425 "content": "draft body",
426 "tags": [],
427 "state": "draft"
428 })))
429 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
430 "content_block_id": "id-wip"
431 })))
432 .expect(1)
433 .mount(&server)
434 .await;
435 let client = make_client(&server);
436 let cb = ContentBlock {
437 name: "wip".into(),
438 description: None,
439 content: "draft body".into(),
440 tags: vec![],
441 state: ContentBlockState::Draft,
442 };
443 client.create_content_block(&cb).await.unwrap();
444 }
445
446 #[tokio::test]
447 async fn update_sends_id_in_body_and_omits_state() {
448 let server = MockServer::start().await;
456 Mock::given(method("POST"))
457 .and(path("/content_blocks/update"))
458 .and(body_json(json!({
459 "content_block_id": "id-1",
460 "name": "promo",
461 "content": "Updated body",
462 "tags": []
463 })))
464 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"message": "success"})))
465 .mount(&server)
466 .await;
467
468 let client = make_client(&server);
469 let cb = ContentBlock {
473 name: "promo".into(),
474 description: None,
475 content: "Updated body".into(),
476 tags: vec![],
477 state: ContentBlockState::Draft,
478 };
479 client.update_content_block("id-1", &cb).await.unwrap();
480 }
481
482 #[tokio::test]
483 async fn update_unauthorized_propagates() {
484 let server = MockServer::start().await;
485 Mock::given(method("POST"))
486 .and(path("/content_blocks/update"))
487 .respond_with(ResponseTemplate::new(401).set_body_string("invalid"))
488 .mount(&server)
489 .await;
490 let client = make_client(&server);
491 let cb = ContentBlock {
492 name: "x".into(),
493 description: None,
494 content: String::new(),
495 tags: vec![],
496 state: ContentBlockState::Active,
497 };
498 let err = client.update_content_block("id", &cb).await.unwrap_err();
499 assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
500 }
501
502 #[tokio::test]
503 async fn update_server_error_is_http() {
504 let server = MockServer::start().await;
505 Mock::given(method("POST"))
506 .and(path("/content_blocks/update"))
507 .respond_with(ResponseTemplate::new(500).set_body_string("oops"))
508 .mount(&server)
509 .await;
510 let client = make_client(&server);
511 let cb = ContentBlock {
512 name: "x".into(),
513 description: None,
514 content: String::new(),
515 tags: vec![],
516 state: ContentBlockState::Active,
517 };
518 let err = client.update_content_block("id", &cb).await.unwrap_err();
519 match err {
520 BrazeApiError::Http { status, body } => {
521 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
522 assert!(body.contains("oops"));
523 }
524 other => panic!("expected Http, got {other:?}"),
525 }
526 }
527
528 #[tokio::test]
529 async fn list_errors_when_count_exceeds_returned() {
530 let server = MockServer::start().await;
531 let entries: Vec<serde_json::Value> = (0..100)
532 .map(|i| {
533 json!({
534 "content_block_id": format!("id-{i}"),
535 "name": format!("block-{i}")
536 })
537 })
538 .collect();
539 Mock::given(method("GET"))
540 .and(path("/content_blocks/list"))
541 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
542 "count": 250,
543 "content_blocks": entries,
544 "message": "success"
545 })))
546 .mount(&server)
547 .await;
548 let client = make_client(&server);
549 let err = client.list_content_blocks().await.unwrap_err();
550 match err {
551 BrazeApiError::PaginationNotImplemented { endpoint, detail } => {
552 assert_eq!(endpoint, "/content_blocks/list");
553 assert!(detail.contains("100"), "detail: {detail}");
554 assert!(detail.contains("250"), "detail: {detail}");
555 }
556 other => panic!("expected PaginationNotImplemented, got {other:?}"),
557 }
558 }
559
560 #[tokio::test]
561 async fn list_errors_on_full_page_with_no_count_field() {
562 let server = MockServer::start().await;
565 let entries: Vec<serde_json::Value> = (0..100)
566 .map(|i| {
567 json!({
568 "content_block_id": format!("id-{i}"),
569 "name": format!("block-{i}")
570 })
571 })
572 .collect();
573 Mock::given(method("GET"))
574 .and(path("/content_blocks/list"))
575 .respond_with(
576 ResponseTemplate::new(200).set_body_json(json!({ "content_blocks": entries })),
577 )
578 .mount(&server)
579 .await;
580 let client = make_client(&server);
581 let err = client.list_content_blocks().await.unwrap_err();
582 assert!(
583 matches!(err, BrazeApiError::PaginationNotImplemented { .. }),
584 "got {err:?}"
585 );
586 }
587
588 #[tokio::test]
589 async fn list_short_page_with_no_count_is_trusted_as_complete() {
590 let server = MockServer::start().await;
598 Mock::given(method("GET"))
599 .and(path("/content_blocks/list"))
600 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
601 "content_blocks": [
602 {"content_block_id": "id-1", "name": "a"},
603 {"content_block_id": "id-2", "name": "b"}
604 ]
605 })))
606 .mount(&server)
607 .await;
608 let client = make_client(&server);
609 let summaries = client.list_content_blocks().await.unwrap();
610 assert_eq!(summaries.len(), 2);
611 assert_eq!(summaries[0].name, "a");
612 assert_eq!(summaries[1].name, "b");
613 }
614
615 #[tokio::test]
616 async fn list_succeeds_when_count_matches_full_page_exactly() {
617 let server = MockServer::start().await;
620 let entries: Vec<serde_json::Value> = (0..100)
621 .map(|i| {
622 json!({
623 "content_block_id": format!("id-{i}"),
624 "name": format!("block-{i}")
625 })
626 })
627 .collect();
628 Mock::given(method("GET"))
629 .and(path("/content_blocks/list"))
630 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
631 "count": 100,
632 "content_blocks": entries
633 })))
634 .mount(&server)
635 .await;
636 let client = make_client(&server);
637 let summaries = client.list_content_blocks().await.unwrap();
638 assert_eq!(summaries.len(), 100);
639 }
640
641 #[tokio::test]
642 async fn list_errors_on_duplicate_name_in_response() {
643 let server = MockServer::start().await;
649 Mock::given(method("GET"))
650 .and(path("/content_blocks/list"))
651 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
652 "count": 2,
653 "content_blocks": [
654 {"content_block_id": "id-a", "name": "dup"},
655 {"content_block_id": "id-b", "name": "dup"}
656 ]
657 })))
658 .mount(&server)
659 .await;
660 let client = make_client(&server);
661 let err = client.list_content_blocks().await.unwrap_err();
662 match err {
663 BrazeApiError::DuplicateNameInListResponse { endpoint, name } => {
664 assert_eq!(endpoint, "/content_blocks/list");
665 assert_eq!(name, "dup");
666 }
667 other => panic!("expected DuplicateNameInListResponse, got {other:?}"),
668 }
669 }
670
671 #[tokio::test]
672 async fn list_retries_on_429_then_succeeds() {
673 let server = MockServer::start().await;
674 Mock::given(method("GET"))
675 .and(path("/content_blocks/list"))
676 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
677 "content_blocks": [{"content_block_id": "id-x", "name": "x"}]
678 })))
679 .mount(&server)
680 .await;
681 Mock::given(method("GET"))
682 .and(path("/content_blocks/list"))
683 .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
684 .up_to_n_times(1)
685 .mount(&server)
686 .await;
687 let client = make_client(&server);
688 let summaries = client.list_content_blocks().await.unwrap();
689 assert_eq!(summaries.len(), 1);
690 assert_eq!(summaries[0].name, "x");
691 }
692}