use crate::braze::error::BrazeApiError;
use crate::braze::{
check_duplicate_names, check_pagination, classify_info_message, BrazeClient, InfoMessageClass,
};
use crate::resource::{ContentBlock, ContentBlockState};
use serde::{Deserialize, Serialize};
const LIST_LIMIT: u32 = 100;
#[derive(Debug, Clone, PartialEq)]
pub struct ContentBlockSummary {
pub content_block_id: String,
pub name: String,
}
impl BrazeClient {
pub async fn list_content_blocks(&self) -> Result<Vec<ContentBlockSummary>, BrazeApiError> {
let req = self
.get(&["content_blocks", "list"])
.query(&[("limit", LIST_LIMIT.to_string())]);
let resp: ContentBlockListResponse = self.send_json(req).await?;
let returned = resp.content_blocks.len();
check_pagination(
resp.count,
returned,
LIST_LIMIT as usize,
"/content_blocks/list",
)?;
check_duplicate_names(
resp.content_blocks.iter().map(|e| e.name.as_str()),
resp.content_blocks.len(),
"/content_blocks/list",
)?;
Ok(resp
.content_blocks
.into_iter()
.map(|w| ContentBlockSummary {
content_block_id: w.content_block_id,
name: w.name,
})
.collect())
}
pub async fn get_content_block(&self, id: &str) -> Result<ContentBlock, BrazeApiError> {
let req = self
.get(&["content_blocks", "info"])
.query(&[("content_block_id", id)]);
let wire: ContentBlockInfoResponse = self.send_json(req).await?;
match classify_info_message(wire.message.as_deref(), "no content block") {
InfoMessageClass::Success => {}
InfoMessageClass::NotFound => {
return Err(BrazeApiError::NotFound {
resource: format!("content_block id '{id}'"),
});
}
InfoMessageClass::Unexpected(message) => {
return Err(BrazeApiError::UnexpectedApiMessage {
endpoint: "/content_blocks/info",
message,
});
}
}
Ok(ContentBlock {
name: wire.name,
description: wire.description,
content: wire.content,
tags: wire.tags,
state: ContentBlockState::Active,
})
}
pub async fn create_content_block(&self, cb: &ContentBlock) -> Result<String, BrazeApiError> {
let body = ContentBlockWriteBody {
content_block_id: None,
name: &cb.name,
description: cb.description.as_deref(),
content: &cb.content,
tags: &cb.tags,
state: Some(cb.state),
};
let req = self.post(&["content_blocks", "create"]).json(&body);
let resp: ContentBlockCreateResponse = self.send_json(req).await?;
Ok(resp.content_block_id)
}
pub async fn update_content_block(
&self,
id: &str,
cb: &ContentBlock,
) -> Result<(), BrazeApiError> {
let body = ContentBlockWriteBody {
content_block_id: Some(id),
name: &cb.name,
description: cb.description.as_deref(),
content: &cb.content,
tags: &cb.tags,
state: None,
};
let req = self.post(&["content_blocks", "update"]).json(&body);
self.send_ok(req).await
}
}
#[derive(Debug, Deserialize)]
struct ContentBlockListResponse {
#[serde(default)]
content_blocks: Vec<ContentBlockListEntry>,
#[serde(default)]
count: Option<usize>,
}
#[derive(Debug, Deserialize)]
struct ContentBlockListEntry {
content_block_id: String,
name: String,
}
#[derive(Debug, Deserialize)]
struct ContentBlockInfoResponse {
#[serde(default)]
name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(default)]
content: String,
#[serde(default)]
tags: Vec<String>,
#[serde(default)]
message: Option<String>,
}
#[derive(Serialize)]
struct ContentBlockWriteBody<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
content_block_id: Option<&'a str>,
name: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<&'a str>,
content: &'a str,
tags: &'a [String],
#[serde(skip_serializing_if = "Option::is_none")]
state: Option<ContentBlockState>,
}
#[derive(Debug, Deserialize)]
struct ContentBlockCreateResponse {
content_block_id: String,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::braze::test_client as make_client;
use reqwest::StatusCode;
use serde_json::json;
use wiremock::matchers::{body_json, header, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn list_happy_path() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/content_blocks/list"))
.and(header("authorization", "Bearer test-key"))
.and(query_param("limit", "100"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"count": 2,
"content_blocks": [
{"content_block_id": "id-1", "name": "promo"},
{"content_block_id": "id-2", "name": "header"}
],
"message": "success"
})))
.mount(&server)
.await;
let client = make_client(&server);
let summaries = client.list_content_blocks().await.unwrap();
assert_eq!(summaries.len(), 2);
assert_eq!(summaries[0].content_block_id, "id-1");
assert_eq!(summaries[0].name, "promo");
}
#[tokio::test]
async fn list_empty_array() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/content_blocks/list"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"content_blocks": []})))
.mount(&server)
.await;
let client = make_client(&server);
assert!(client.list_content_blocks().await.unwrap().is_empty());
}
#[tokio::test]
async fn list_ignores_unknown_fields() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/content_blocks/list"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"content_blocks": [{
"content_block_id": "id-1",
"name": "promo",
"content_type": "html",
"liquid_tag": "{{content_blocks.${promo}}}",
"future_metadata": {"foo": "bar"}
}]
})))
.mount(&server)
.await;
let client = make_client(&server);
let summaries = client.list_content_blocks().await.unwrap();
assert_eq!(summaries.len(), 1);
assert_eq!(summaries[0].name, "promo");
}
#[tokio::test]
async fn list_unauthorized() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/content_blocks/list"))
.respond_with(ResponseTemplate::new(401).set_body_string("invalid"))
.mount(&server)
.await;
let client = make_client(&server);
let err = client.list_content_blocks().await.unwrap_err();
assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
}
#[tokio::test]
async fn info_happy_path() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/content_blocks/info"))
.and(query_param("content_block_id", "id-1"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"content_block_id": "id-1",
"name": "promo",
"description": "Promo banner",
"content": "Hello {{ user.${first_name} }}",
"tags": ["pr", "dialog"],
"content_type": "html",
"message": "success"
})))
.mount(&server)
.await;
let client = make_client(&server);
let cb = client.get_content_block("id-1").await.unwrap();
assert_eq!(cb.name, "promo");
assert_eq!(cb.description.as_deref(), Some("Promo banner"));
assert_eq!(cb.content, "Hello {{ user.${first_name} }}");
assert_eq!(cb.tags, vec!["pr".to_string(), "dialog".to_string()]);
assert_eq!(cb.state, ContentBlockState::Active);
}
#[tokio::test]
async fn info_with_unrecognised_error_message_surfaces_as_unexpected() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/content_blocks/info"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"message": "Internal server hiccup, please retry"
})))
.mount(&server)
.await;
let client = make_client(&server);
let err = client.get_content_block("some-id").await.unwrap_err();
match err {
BrazeApiError::UnexpectedApiMessage { endpoint, message } => {
assert_eq!(endpoint, "/content_blocks/info");
assert!(
message.contains("Internal server hiccup"),
"message not preserved verbatim: {message}"
);
}
other => panic!("expected UnexpectedApiMessage, got {other:?}"),
}
}
#[tokio::test]
async fn info_with_unsuccessful_message_is_not_found() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/content_blocks/info"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"message": "No content block with id 'missing' found"
})))
.mount(&server)
.await;
let client = make_client(&server);
let err = client.get_content_block("missing").await.unwrap_err();
match err {
BrazeApiError::NotFound { resource } => assert!(resource.contains("missing")),
other => panic!("expected NotFound, got {other:?}"),
}
}
#[tokio::test]
async fn create_sends_correct_body_and_returns_id() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/content_blocks/create"))
.and(header("authorization", "Bearer test-key"))
.and(body_json(json!({
"name": "promo",
"description": "Promo banner",
"content": "Hello",
"tags": ["pr"],
"state": "active"
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"content_block_id": "new-id-123",
"message": "success"
})))
.mount(&server)
.await;
let client = make_client(&server);
let cb = ContentBlock {
name: "promo".into(),
description: Some("Promo banner".into()),
content: "Hello".into(),
tags: vec!["pr".into()],
state: ContentBlockState::Active,
};
let id = client.create_content_block(&cb).await.unwrap();
assert_eq!(id, "new-id-123");
}
#[tokio::test]
async fn create_omits_description_when_none() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/content_blocks/create"))
.and(body_json(json!({
"name": "minimal",
"content": "x",
"tags": [],
"state": "active"
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"content_block_id": "id-min"
})))
.mount(&server)
.await;
let client = make_client(&server);
let cb = ContentBlock {
name: "minimal".into(),
description: None,
content: "x".into(),
tags: vec![],
state: ContentBlockState::Active,
};
client.create_content_block(&cb).await.unwrap();
}
#[tokio::test]
async fn create_forwards_draft_state_to_request_body() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/content_blocks/create"))
.and(body_json(json!({
"name": "wip",
"content": "draft body",
"tags": [],
"state": "draft"
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"content_block_id": "id-wip"
})))
.expect(1)
.mount(&server)
.await;
let client = make_client(&server);
let cb = ContentBlock {
name: "wip".into(),
description: None,
content: "draft body".into(),
tags: vec![],
state: ContentBlockState::Draft,
};
client.create_content_block(&cb).await.unwrap();
}
#[tokio::test]
async fn update_sends_id_in_body_and_omits_state() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/content_blocks/update"))
.and(body_json(json!({
"content_block_id": "id-1",
"name": "promo",
"content": "Updated body",
"tags": []
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"message": "success"})))
.mount(&server)
.await;
let client = make_client(&server);
let cb = ContentBlock {
name: "promo".into(),
description: None,
content: "Updated body".into(),
tags: vec![],
state: ContentBlockState::Draft,
};
client.update_content_block("id-1", &cb).await.unwrap();
}
#[tokio::test]
async fn update_unauthorized_propagates() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/content_blocks/update"))
.respond_with(ResponseTemplate::new(401).set_body_string("invalid"))
.mount(&server)
.await;
let client = make_client(&server);
let cb = ContentBlock {
name: "x".into(),
description: None,
content: String::new(),
tags: vec![],
state: ContentBlockState::Active,
};
let err = client.update_content_block("id", &cb).await.unwrap_err();
assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
}
#[tokio::test]
async fn update_server_error_is_http() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/content_blocks/update"))
.respond_with(ResponseTemplate::new(500).set_body_string("oops"))
.mount(&server)
.await;
let client = make_client(&server);
let cb = ContentBlock {
name: "x".into(),
description: None,
content: String::new(),
tags: vec![],
state: ContentBlockState::Active,
};
let err = client.update_content_block("id", &cb).await.unwrap_err();
match err {
BrazeApiError::Http { status, body } => {
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert!(body.contains("oops"));
}
other => panic!("expected Http, got {other:?}"),
}
}
#[tokio::test]
async fn list_errors_when_count_exceeds_returned() {
let server = MockServer::start().await;
let entries: Vec<serde_json::Value> = (0..100)
.map(|i| {
json!({
"content_block_id": format!("id-{i}"),
"name": format!("block-{i}")
})
})
.collect();
Mock::given(method("GET"))
.and(path("/content_blocks/list"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"count": 250,
"content_blocks": entries,
"message": "success"
})))
.mount(&server)
.await;
let client = make_client(&server);
let err = client.list_content_blocks().await.unwrap_err();
match err {
BrazeApiError::PaginationNotImplemented { endpoint, detail } => {
assert_eq!(endpoint, "/content_blocks/list");
assert!(detail.contains("100"), "detail: {detail}");
assert!(detail.contains("250"), "detail: {detail}");
}
other => panic!("expected PaginationNotImplemented, got {other:?}"),
}
}
#[tokio::test]
async fn list_errors_on_full_page_with_no_count_field() {
let server = MockServer::start().await;
let entries: Vec<serde_json::Value> = (0..100)
.map(|i| {
json!({
"content_block_id": format!("id-{i}"),
"name": format!("block-{i}")
})
})
.collect();
Mock::given(method("GET"))
.and(path("/content_blocks/list"))
.respond_with(
ResponseTemplate::new(200).set_body_json(json!({ "content_blocks": entries })),
)
.mount(&server)
.await;
let client = make_client(&server);
let err = client.list_content_blocks().await.unwrap_err();
assert!(
matches!(err, BrazeApiError::PaginationNotImplemented { .. }),
"got {err:?}"
);
}
#[tokio::test]
async fn list_short_page_with_no_count_is_trusted_as_complete() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/content_blocks/list"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"content_blocks": [
{"content_block_id": "id-1", "name": "a"},
{"content_block_id": "id-2", "name": "b"}
]
})))
.mount(&server)
.await;
let client = make_client(&server);
let summaries = client.list_content_blocks().await.unwrap();
assert_eq!(summaries.len(), 2);
assert_eq!(summaries[0].name, "a");
assert_eq!(summaries[1].name, "b");
}
#[tokio::test]
async fn list_succeeds_when_count_matches_full_page_exactly() {
let server = MockServer::start().await;
let entries: Vec<serde_json::Value> = (0..100)
.map(|i| {
json!({
"content_block_id": format!("id-{i}"),
"name": format!("block-{i}")
})
})
.collect();
Mock::given(method("GET"))
.and(path("/content_blocks/list"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"count": 100,
"content_blocks": entries
})))
.mount(&server)
.await;
let client = make_client(&server);
let summaries = client.list_content_blocks().await.unwrap();
assert_eq!(summaries.len(), 100);
}
#[tokio::test]
async fn list_errors_on_duplicate_name_in_response() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/content_blocks/list"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"count": 2,
"content_blocks": [
{"content_block_id": "id-a", "name": "dup"},
{"content_block_id": "id-b", "name": "dup"}
]
})))
.mount(&server)
.await;
let client = make_client(&server);
let err = client.list_content_blocks().await.unwrap_err();
match err {
BrazeApiError::DuplicateNameInListResponse { endpoint, name } => {
assert_eq!(endpoint, "/content_blocks/list");
assert_eq!(name, "dup");
}
other => panic!("expected DuplicateNameInListResponse, got {other:?}"),
}
}
#[tokio::test]
async fn list_retries_on_429_then_succeeds() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/content_blocks/list"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"content_blocks": [{"content_block_id": "id-x", "name": "x"}]
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/content_blocks/list"))
.respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
.up_to_n_times(1)
.mount(&server)
.await;
let client = make_client(&server);
let summaries = client.list_content_blocks().await.unwrap();
assert_eq!(summaries.len(), 1);
assert_eq!(summaries[0].name, "x");
}
}