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