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