Skip to main content

braze_sync/braze/
content_block.rs

1//! Content Block endpoints.
2//!
3//! Braze identifies content blocks by `content_block_id` but the templates
4//! that consume them reference `name`, so the workflow is always
5//! list-then-translate. There is no DELETE endpoint, which is why
6//! remote-only blocks are surfaced as orphans rather than `Removed` diffs.
7
8use 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    /// Braze has no `has_more`/cursor field, so we loop until a short page.
26    /// `offset` is omitted on the first request because this endpoint rejects
27    /// `offset=0` with `400 "Offset must be greater than 0."`.
28    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    /// Braze returns 200 with a non-success `message` field for unknown
70    /// ids instead of a 404, so we need to discriminate here rather than
71    /// relying on HTTP status. Recognised not-found phrases remap to
72    /// `NotFound` so callers can branch cleanly; any other non-"success"
73    /// message surfaces verbatim as `UnexpectedApiMessage` so a real
74    /// failure is not silently swallowed.
75    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            // Braze /info has no state field; default keeps round-trips
100            // stable. See diff/content_block.rs syncable_eq for why this
101            // can't drift the diff layer.
102            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            // Create is the one time braze-sync communicates an initial
114            // state to Braze. On update we omit state entirely — see the
115            // note on update_content_block.
116            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    /// Used both for body changes and for the `--archive-orphans` rename
124    /// (same id, `[ARCHIVED-...]` name).
125    ///
126    /// `state` is intentionally omitted from the request body. The
127    /// diff layer excludes it from `syncable_eq` (there is no state
128    /// field on `/content_blocks/info`, so we cannot read it back and
129    /// cannot compare it), and the README documents it as a local-only
130    /// field. Forwarding `cb.state` here would let local edits leak
131    /// into Braze piggyback-style whenever another field changed, and
132    /// could silently overwrite a real remote state that braze-sync
133    /// has no way to observe — the same "infinite drift" trap the
134    /// honest-orphan design exists to avoid. Leaving state off makes
135    /// the wire-level behavior match the documented semantics.
136    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/// Wire body shared by `/content_blocks/create` and `.../update`. Both
181/// endpoints are replace-all on the fields serialized here: `tags` is
182/// always sent (an empty array drops every tag server-side) and
183/// `content` overwrites the current body. `description` is sent when
184/// `Some` (including `Some("")` — see `diff::content_block::desc_eq`
185/// for why empty-string is semantically equivalent to no description
186/// at diff time but still goes over the wire if present locally).
187/// `state` is the one field we intentionally do NOT round-trip on
188/// update — see the doc comment on `update_content_block`.
189#[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        // Braze does not return state; we default to Active.
315        assert_eq!(cb.state, ContentBlockState::Active);
316    }
317
318    #[tokio::test]
319    async fn info_with_unrecognised_error_message_surfaces_as_unexpected() {
320        // Regression guard: before this change, any non-"success"
321        // message was blanket-remapped to NotFound, which would silently
322        // mask a real server-side failure as a missing id. The classifier
323        // now only remaps known not-found phrases, so a novel message
324        // has to come back as `UnexpectedApiMessage`.
325        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        // Counterpart to `update_sends_id_in_body_and_omits_state`: on
427        // create, state IS sent. The only difference between Active and
428        // Draft round-trips is the body_json matcher, so pinning Draft
429        // here locks in both serde variants going over the wire.
430        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        // Pins two invariants: the update body carries
459        // `content_block_id` (so Braze knows which block to modify),
460        // and it does NOT carry a `state` field. State is local-only
461        // per the README and `diff::content_block::syncable_eq`;
462        // sending it here would let a local `state: draft` silently
463        // overwrite the remote whenever another field happened to
464        // change.
465        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        // Deliberately pick Draft here: if the client still forwarded
480        // state on update, `body_json` above would fail to match
481        // because the body would carry `"state": "draft"`.
482        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        // Regression guard: if Braze ever violates its own name-uniqueness
621        // contract, the BTreeMap-based name→id index in
622        // `diff::compute_content_block_plan` would silently keep only
623        // the last id for a duplicate pair, hiding one of the two blocks
624        // from every subsequent list/update/archive op. Fail loud instead.
625        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}