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    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    /// Braze returns 200 with a non-success `message` field for unknown
66    /// ids instead of a 404, so we need to discriminate here rather than
67    /// relying on HTTP status. Recognised not-found phrases remap to
68    /// `NotFound` so callers can branch cleanly; any other non-"success"
69    /// message surfaces verbatim as `UnexpectedApiMessage` so a real
70    /// failure is not silently swallowed. The wire shapes are ASSUMED
71    /// per IMPLEMENTATION.md §8.3 — a blanket "non-success → NotFound"
72    /// rule would misclassify every future surprise as a missing id.
73    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            // Braze /info has no state field; default keeps round-trips
98            // stable. See diff/content_block.rs syncable_eq for why this
99            // can't drift the diff layer.
100            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            // Create is the one time braze-sync communicates an initial
112            // state to Braze. On update we omit state entirely — see the
113            // note on update_content_block.
114            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    /// Used both for body changes and for the `--archive-orphans` rename
122    /// (same id, `[ARCHIVED-...]` name).
123    ///
124    /// `state` is intentionally omitted from the request body. The
125    /// diff layer excludes it from `syncable_eq` (there is no state
126    /// field on `/content_blocks/info`, so we cannot read it back and
127    /// cannot compare it), and the README documents it as a local-only
128    /// field. Forwarding `cb.state` here would let local edits leak
129    /// into Braze piggyback-style whenever another field changed, and
130    /// could silently overwrite a real remote state that braze-sync
131    /// has no way to observe — the same "infinite drift" trap the
132    /// honest-orphan design exists to avoid. Leaving state off makes
133    /// the wire-level behavior match the documented semantics.
134    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/// Wire body shared by `/content_blocks/create` and `.../update`. Both
179/// endpoints are replace-all on the fields serialized here: `tags` is
180/// always sent (an empty array drops every tag server-side) and
181/// `content` overwrites the current body. `description` is sent when
182/// `Some` (including `Some("")` — see `diff::content_block::desc_eq`
183/// for why empty-string is semantically equivalent to no description
184/// at diff time but still goes over the wire if present locally).
185/// `state` is the one field we intentionally do NOT round-trip on
186/// update — see the doc comment on `update_content_block`.
187#[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        // Braze does not return state; we default to Active.
311        assert_eq!(cb.state, ContentBlockState::Active);
312    }
313
314    #[tokio::test]
315    async fn info_with_unrecognised_error_message_surfaces_as_unexpected() {
316        // Regression guard: before this change, any non-"success"
317        // message was blanket-remapped to NotFound, which would silently
318        // mask a real server-side failure as a missing id. The classifier
319        // now only remaps known not-found phrases, so a novel message
320        // has to come back as `UnexpectedApiMessage`.
321        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        // Counterpart to `update_sends_id_in_body_and_omits_state`: on
423        // create, state IS sent. The only difference between Active and
424        // Draft round-trips is the body_json matcher, so pinning Draft
425        // here locks in both serde variants going over the wire.
426        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        // Pins two invariants: the update body carries
455        // `content_block_id` (so Braze knows which block to modify),
456        // and it does NOT carry a `state` field. State is local-only
457        // per the README and `diff::content_block::syncable_eq`;
458        // sending it here would let a local `state: draft` silently
459        // overwrite the remote whenever another field happened to
460        // change.
461        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        // Deliberately pick Draft here: if the client still forwarded
476        // state on update, `body_json` above would fail to match
477        // because the body would carry `"state": "draft"`.
478        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        // Regression guard: if Braze ever violates its own name-uniqueness
617        // contract, the BTreeMap-based name→id index in
618        // `diff::compute_content_block_plan` would silently keep only
619        // the last id for a duplicate pair, hiding one of the two blocks
620        // from every subsequent list/update/archive op. Fail loud instead.
621        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}