1use crate::braze::error::BrazeApiError;
9use crate::braze::{
10 check_duplicate_names, classify_info_message, BrazeClient, InfoMessageClass,
11 LIST_SAFETY_CAP_ITEMS,
12};
13use crate::resource::{ContentBlock, ContentBlockState};
14use serde::{Deserialize, Serialize};
15
16const LIST_LIMIT: u32 = 1000;
17
18#[derive(Debug, Clone, PartialEq)]
19pub struct ContentBlockSummary {
20 pub content_block_id: String,
21 pub name: String,
22}
23
24impl BrazeClient {
25 pub async fn list_content_blocks(&self) -> Result<Vec<ContentBlockSummary>, BrazeApiError> {
29 let mut all: Vec<ContentBlockListEntry> = Vec::with_capacity(LIST_LIMIT as usize);
30 let mut offset: u32 = 0;
31 loop {
32 let mut req = self
33 .get(&["content_blocks", "list"])
34 .query(&[("limit", LIST_LIMIT.to_string())]);
35 if offset > 0 {
36 req = req.query(&[("offset", offset.to_string())]);
37 }
38 let resp: ContentBlockListResponse = self.send_json(req).await?;
39 let page_len = resp.content_blocks.len();
40 if all.len().saturating_add(page_len) > LIST_SAFETY_CAP_ITEMS {
41 return Err(BrazeApiError::PaginationNotImplemented {
42 endpoint: "/content_blocks/list",
43 detail: format!("would exceed {LIST_SAFETY_CAP_ITEMS} item safety cap"),
44 });
45 }
46 all.extend(resp.content_blocks);
47
48 if page_len < LIST_LIMIT as usize {
49 break;
50 }
51 offset += LIST_LIMIT;
52 }
53
54 check_duplicate_names(
55 all.iter().map(|e| e.name.as_str()),
56 all.len(),
57 "/content_blocks/list",
58 )?;
59
60 Ok(all
61 .into_iter()
62 .map(|w| ContentBlockSummary {
63 content_block_id: w.content_block_id,
64 name: w.name,
65 })
66 .collect())
67 }
68
69 pub async fn get_content_block(&self, id: &str) -> Result<ContentBlock, BrazeApiError> {
78 let req = self
79 .get(&["content_blocks", "info"])
80 .query(&[("content_block_id", id)]);
81 let wire: ContentBlockInfoResponse = self.send_json(req).await?;
82 match classify_info_message(wire.message.as_deref(), "no content block") {
83 InfoMessageClass::Success => {}
84 InfoMessageClass::NotFound => {
85 return Err(BrazeApiError::NotFound {
86 resource: format!("content_block id '{id}'"),
87 });
88 }
89 InfoMessageClass::Unexpected(message) => {
90 return Err(BrazeApiError::UnexpectedApiMessage {
91 endpoint: "/content_blocks/info",
92 message,
93 });
94 }
95 }
96 Ok(ContentBlock {
97 name: wire.name,
98 description: wire.description,
99 content: wire.content,
100 tags: wire.tags,
101 state: ContentBlockState::Active,
105 })
106 }
107
108 pub async fn create_content_block(&self, cb: &ContentBlock) -> Result<String, BrazeApiError> {
109 let body = ContentBlockWriteBody {
110 content_block_id: None,
111 name: &cb.name,
112 description: cb.description.as_deref(),
113 content: &cb.content,
114 tags: &cb.tags,
115 state: Some(cb.state),
119 };
120 let req = self.post(&["content_blocks", "create"]).json(&body);
121 let resp: ContentBlockCreateResponse = self.send_json(req).await?;
122 Ok(resp.content_block_id)
123 }
124
125 pub async fn update_content_block(
139 &self,
140 id: &str,
141 cb: &ContentBlock,
142 ) -> Result<(), BrazeApiError> {
143 let body = ContentBlockWriteBody {
144 content_block_id: Some(id),
145 name: &cb.name,
146 description: cb.description.as_deref(),
147 content: &cb.content,
148 tags: &cb.tags,
149 state: None,
150 };
151 let req = self.post(&["content_blocks", "update"]).json(&body);
152 self.send_ok(req).await
153 }
154}
155
156#[derive(Debug, Deserialize)]
157struct ContentBlockListResponse {
158 #[serde(default)]
159 content_blocks: Vec<ContentBlockListEntry>,
160}
161
162#[derive(Debug, Deserialize)]
163struct ContentBlockListEntry {
164 content_block_id: String,
165 name: String,
166}
167
168#[derive(Debug, Deserialize)]
169struct ContentBlockInfoResponse {
170 #[serde(default)]
171 name: String,
172 #[serde(default, skip_serializing_if = "Option::is_none")]
173 description: Option<String>,
174 #[serde(default)]
175 content: String,
176 #[serde(default)]
177 tags: Vec<String>,
178 #[serde(default)]
179 message: Option<String>,
180}
181
182#[derive(Serialize)]
192struct ContentBlockWriteBody<'a> {
193 #[serde(skip_serializing_if = "Option::is_none")]
194 content_block_id: Option<&'a str>,
195 name: &'a str,
196 #[serde(skip_serializing_if = "Option::is_none")]
197 description: Option<&'a str>,
198 content: &'a str,
199 tags: &'a [String],
200 #[serde(skip_serializing_if = "Option::is_none")]
201 state: Option<ContentBlockState>,
202}
203
204#[derive(Debug, Deserialize)]
205struct ContentBlockCreateResponse {
206 content_block_id: String,
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212 use crate::braze::test_client as make_client;
213 use reqwest::StatusCode;
214 use serde_json::json;
215 use wiremock::matchers::{
216 body_json, header, method, path, query_param, query_param_is_missing,
217 };
218 use wiremock::{Mock, MockServer, ResponseTemplate};
219
220 #[tokio::test]
221 async fn list_happy_path() {
222 let server = MockServer::start().await;
223 Mock::given(method("GET"))
224 .and(path("/content_blocks/list"))
225 .and(header("authorization", "Bearer test-key"))
226 .and(query_param("limit", "1000"))
227 .and(query_param_is_missing("offset"))
228 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
229 "content_blocks": [
230 {"content_block_id": "id-1", "name": "promo"},
231 {"content_block_id": "id-2", "name": "header"}
232 ],
233 "message": "success"
234 })))
235 .mount(&server)
236 .await;
237
238 let client = make_client(&server);
239 let summaries = client.list_content_blocks().await.unwrap();
240 assert_eq!(summaries.len(), 2);
241 assert_eq!(summaries[0].content_block_id, "id-1");
242 assert_eq!(summaries[0].name, "promo");
243 }
244
245 #[tokio::test]
246 async fn list_empty_array() {
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!({"content_blocks": []})))
251 .mount(&server)
252 .await;
253 let client = make_client(&server);
254 assert!(client.list_content_blocks().await.unwrap().is_empty());
255 }
256
257 #[tokio::test]
258 async fn list_ignores_unknown_fields() {
259 let server = MockServer::start().await;
260 Mock::given(method("GET"))
261 .and(path("/content_blocks/list"))
262 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
263 "content_blocks": [{
264 "content_block_id": "id-1",
265 "name": "promo",
266 "content_type": "html",
267 "liquid_tag": "{{content_blocks.${promo}}}",
268 "future_metadata": {"foo": "bar"}
269 }]
270 })))
271 .mount(&server)
272 .await;
273 let client = make_client(&server);
274 let summaries = client.list_content_blocks().await.unwrap();
275 assert_eq!(summaries.len(), 1);
276 assert_eq!(summaries[0].name, "promo");
277 }
278
279 #[tokio::test]
280 async fn list_unauthorized() {
281 let server = MockServer::start().await;
282 Mock::given(method("GET"))
283 .and(path("/content_blocks/list"))
284 .respond_with(ResponseTemplate::new(401).set_body_string("invalid"))
285 .mount(&server)
286 .await;
287 let client = make_client(&server);
288 let err = client.list_content_blocks().await.unwrap_err();
289 assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
290 }
291
292 #[tokio::test]
293 async fn info_happy_path() {
294 let server = MockServer::start().await;
295 Mock::given(method("GET"))
296 .and(path("/content_blocks/info"))
297 .and(query_param("content_block_id", "id-1"))
298 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
299 "content_block_id": "id-1",
300 "name": "promo",
301 "description": "Promo banner",
302 "content": "Hello {{ user.${first_name} }}",
303 "tags": ["pr", "dialog"],
304 "content_type": "html",
305 "message": "success"
306 })))
307 .mount(&server)
308 .await;
309
310 let client = make_client(&server);
311 let cb = client.get_content_block("id-1").await.unwrap();
312 assert_eq!(cb.name, "promo");
313 assert_eq!(cb.description.as_deref(), Some("Promo banner"));
314 assert_eq!(cb.content, "Hello {{ user.${first_name} }}");
315 assert_eq!(cb.tags, vec!["pr".to_string(), "dialog".to_string()]);
316 assert_eq!(cb.state, ContentBlockState::Active);
318 }
319
320 #[tokio::test]
321 async fn info_with_unrecognised_error_message_surfaces_as_unexpected() {
322 let server = MockServer::start().await;
328 Mock::given(method("GET"))
329 .and(path("/content_blocks/info"))
330 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
331 "message": "Internal server hiccup, please retry"
332 })))
333 .mount(&server)
334 .await;
335 let client = make_client(&server);
336 let err = client.get_content_block("some-id").await.unwrap_err();
337 match err {
338 BrazeApiError::UnexpectedApiMessage { endpoint, message } => {
339 assert_eq!(endpoint, "/content_blocks/info");
340 assert!(
341 message.contains("Internal server hiccup"),
342 "message not preserved verbatim: {message}"
343 );
344 }
345 other => panic!("expected UnexpectedApiMessage, got {other:?}"),
346 }
347 }
348
349 #[tokio::test]
350 async fn info_with_unsuccessful_message_is_not_found() {
351 let server = MockServer::start().await;
352 Mock::given(method("GET"))
353 .and(path("/content_blocks/info"))
354 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
355 "message": "No content block with id 'missing' found"
356 })))
357 .mount(&server)
358 .await;
359 let client = make_client(&server);
360 let err = client.get_content_block("missing").await.unwrap_err();
361 match err {
362 BrazeApiError::NotFound { resource } => assert!(resource.contains("missing")),
363 other => panic!("expected NotFound, got {other:?}"),
364 }
365 }
366
367 #[tokio::test]
368 async fn create_sends_correct_body_and_returns_id() {
369 let server = MockServer::start().await;
370 Mock::given(method("POST"))
371 .and(path("/content_blocks/create"))
372 .and(header("authorization", "Bearer test-key"))
373 .and(body_json(json!({
374 "name": "promo",
375 "description": "Promo banner",
376 "content": "Hello",
377 "tags": ["pr"],
378 "state": "active"
379 })))
380 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
381 "content_block_id": "new-id-123",
382 "message": "success"
383 })))
384 .mount(&server)
385 .await;
386
387 let client = make_client(&server);
388 let cb = ContentBlock {
389 name: "promo".into(),
390 description: Some("Promo banner".into()),
391 content: "Hello".into(),
392 tags: vec!["pr".into()],
393 state: ContentBlockState::Active,
394 };
395 let id = client.create_content_block(&cb).await.unwrap();
396 assert_eq!(id, "new-id-123");
397 }
398
399 #[tokio::test]
400 async fn create_omits_description_when_none() {
401 let server = MockServer::start().await;
402 Mock::given(method("POST"))
403 .and(path("/content_blocks/create"))
404 .and(body_json(json!({
405 "name": "minimal",
406 "content": "x",
407 "tags": [],
408 "state": "active"
409 })))
410 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
411 "content_block_id": "id-min"
412 })))
413 .mount(&server)
414 .await;
415 let client = make_client(&server);
416 let cb = ContentBlock {
417 name: "minimal".into(),
418 description: None,
419 content: "x".into(),
420 tags: vec![],
421 state: ContentBlockState::Active,
422 };
423 client.create_content_block(&cb).await.unwrap();
424 }
425
426 #[tokio::test]
427 async fn create_forwards_draft_state_to_request_body() {
428 let server = MockServer::start().await;
433 Mock::given(method("POST"))
434 .and(path("/content_blocks/create"))
435 .and(body_json(json!({
436 "name": "wip",
437 "content": "draft body",
438 "tags": [],
439 "state": "draft"
440 })))
441 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
442 "content_block_id": "id-wip"
443 })))
444 .expect(1)
445 .mount(&server)
446 .await;
447 let client = make_client(&server);
448 let cb = ContentBlock {
449 name: "wip".into(),
450 description: None,
451 content: "draft body".into(),
452 tags: vec![],
453 state: ContentBlockState::Draft,
454 };
455 client.create_content_block(&cb).await.unwrap();
456 }
457
458 #[tokio::test]
459 async fn update_sends_id_in_body_and_omits_state() {
460 let server = MockServer::start().await;
468 Mock::given(method("POST"))
469 .and(path("/content_blocks/update"))
470 .and(body_json(json!({
471 "content_block_id": "id-1",
472 "name": "promo",
473 "content": "Updated body",
474 "tags": []
475 })))
476 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"message": "success"})))
477 .mount(&server)
478 .await;
479
480 let client = make_client(&server);
481 let cb = ContentBlock {
485 name: "promo".into(),
486 description: None,
487 content: "Updated body".into(),
488 tags: vec![],
489 state: ContentBlockState::Draft,
490 };
491 client.update_content_block("id-1", &cb).await.unwrap();
492 }
493
494 #[tokio::test]
495 async fn update_unauthorized_propagates() {
496 let server = MockServer::start().await;
497 Mock::given(method("POST"))
498 .and(path("/content_blocks/update"))
499 .respond_with(ResponseTemplate::new(401).set_body_string("invalid"))
500 .mount(&server)
501 .await;
502 let client = make_client(&server);
503 let cb = ContentBlock {
504 name: "x".into(),
505 description: None,
506 content: String::new(),
507 tags: vec![],
508 state: ContentBlockState::Active,
509 };
510 let err = client.update_content_block("id", &cb).await.unwrap_err();
511 assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
512 }
513
514 #[tokio::test]
515 async fn update_server_error_is_http() {
516 let server = MockServer::start().await;
517 Mock::given(method("POST"))
518 .and(path("/content_blocks/update"))
519 .respond_with(ResponseTemplate::new(500).set_body_string("oops"))
520 .mount(&server)
521 .await;
522 let client = make_client(&server);
523 let cb = ContentBlock {
524 name: "x".into(),
525 description: None,
526 content: String::new(),
527 tags: vec![],
528 state: ContentBlockState::Active,
529 };
530 let err = client.update_content_block("id", &cb).await.unwrap_err();
531 match err {
532 BrazeApiError::Http { status, body } => {
533 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
534 assert!(body.contains("oops"));
535 }
536 other => panic!("expected Http, got {other:?}"),
537 }
538 }
539
540 #[tokio::test]
541 async fn list_short_page_is_treated_as_complete() {
542 let server = MockServer::start().await;
543 Mock::given(method("GET"))
544 .and(path("/content_blocks/list"))
545 .and(query_param_is_missing("offset"))
546 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
547 "content_blocks": [
548 {"content_block_id": "id-1", "name": "a"},
549 {"content_block_id": "id-2", "name": "b"}
550 ]
551 })))
552 .mount(&server)
553 .await;
554 let client = make_client(&server);
555 let summaries = client.list_content_blocks().await.unwrap();
556 assert_eq!(summaries.len(), 2);
557 }
558
559 #[tokio::test]
560 async fn list_offset_pagination_across_three_pages() {
561 let server = MockServer::start().await;
562 let page1: Vec<serde_json::Value> = (0..1000)
563 .map(|i| {
564 json!({
565 "content_block_id": format!("id-p1-{i}"),
566 "name": format!("p1_{i}")
567 })
568 })
569 .collect();
570 let page2: Vec<serde_json::Value> = (0..1000)
571 .map(|i| {
572 json!({
573 "content_block_id": format!("id-p2-{i}"),
574 "name": format!("p2_{i}")
575 })
576 })
577 .collect();
578 let page3: Vec<serde_json::Value> = (0..234)
579 .map(|i| {
580 json!({
581 "content_block_id": format!("id-p3-{i}"),
582 "name": format!("p3_{i}")
583 })
584 })
585 .collect();
586 Mock::given(method("GET"))
587 .and(path("/content_blocks/list"))
588 .and(query_param("offset", "2000"))
589 .respond_with(
590 ResponseTemplate::new(200).set_body_json(json!({ "content_blocks": page3 })),
591 )
592 .mount(&server)
593 .await;
594 Mock::given(method("GET"))
595 .and(path("/content_blocks/list"))
596 .and(query_param("offset", "1000"))
597 .respond_with(
598 ResponseTemplate::new(200).set_body_json(json!({ "content_blocks": page2 })),
599 )
600 .mount(&server)
601 .await;
602 Mock::given(method("GET"))
603 .and(path("/content_blocks/list"))
604 .and(query_param_is_missing("offset"))
605 .respond_with(
606 ResponseTemplate::new(200).set_body_json(json!({ "content_blocks": page1 })),
607 )
608 .mount(&server)
609 .await;
610
611 let client = make_client(&server);
612 let summaries = client.list_content_blocks().await.unwrap();
613 assert_eq!(summaries.len(), 2234);
614 assert_eq!(summaries[0].name, "p1_0");
615 assert_eq!(summaries[999].name, "p1_999");
616 assert_eq!(summaries[1000].name, "p2_0");
617 assert_eq!(summaries[2233].name, "p3_233");
618 }
619
620 #[tokio::test]
621 async fn list_errors_on_duplicate_name_in_response() {
622 let server = MockServer::start().await;
628 Mock::given(method("GET"))
629 .and(path("/content_blocks/list"))
630 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
631 "content_blocks": [
632 {"content_block_id": "id-a", "name": "dup"},
633 {"content_block_id": "id-b", "name": "dup"}
634 ]
635 })))
636 .mount(&server)
637 .await;
638 let client = make_client(&server);
639 let err = client.list_content_blocks().await.unwrap_err();
640 match err {
641 BrazeApiError::DuplicateNameInListResponse { endpoint, name } => {
642 assert_eq!(endpoint, "/content_blocks/list");
643 assert_eq!(name, "dup");
644 }
645 other => panic!("expected DuplicateNameInListResponse, got {other:?}"),
646 }
647 }
648
649 #[tokio::test]
650 async fn list_retries_on_429_then_succeeds() {
651 let server = MockServer::start().await;
652 Mock::given(method("GET"))
653 .and(path("/content_blocks/list"))
654 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
655 "content_blocks": [{"content_block_id": "id-x", "name": "x"}]
656 })))
657 .mount(&server)
658 .await;
659 Mock::given(method("GET"))
660 .and(path("/content_blocks/list"))
661 .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
662 .up_to_n_times(1)
663 .mount(&server)
664 .await;
665 let client = make_client(&server);
666 let summaries = client.list_content_blocks().await.unwrap();
667 assert_eq!(summaries.len(), 1);
668 assert_eq!(summaries[0].name, "x");
669 }
670}