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. The wire shapes are ASSUMED
75    /// per IMPLEMENTATION.md §8.3 — a blanket "non-success → NotFound"
76    /// rule would misclassify every future surprise as a missing id.
77    pub async fn get_content_block(&self, id: &str) -> Result<ContentBlock, BrazeApiError> {
78        let req = self
79            .get(&["content_blocks", "info"])
80            .query(&[("content_block_id", id)]);
81        let wire: ContentBlockInfoResponse = self.send_json(req).await?;
82        match classify_info_message(wire.message.as_deref(), "no content block") {
83            InfoMessageClass::Success => {}
84            InfoMessageClass::NotFound => {
85                return Err(BrazeApiError::NotFound {
86                    resource: format!("content_block id '{id}'"),
87                });
88            }
89            InfoMessageClass::Unexpected(message) => {
90                return Err(BrazeApiError::UnexpectedApiMessage {
91                    endpoint: "/content_blocks/info",
92                    message,
93                });
94            }
95        }
96        Ok(ContentBlock {
97            name: wire.name,
98            description: wire.description,
99            content: wire.content,
100            tags: wire.tags,
101            // Braze /info has no state field; default keeps round-trips
102            // stable. See diff/content_block.rs syncable_eq for why this
103            // can't drift the diff layer.
104            state: ContentBlockState::Active,
105        })
106    }
107
108    pub async fn create_content_block(&self, cb: &ContentBlock) -> Result<String, BrazeApiError> {
109        let body = ContentBlockWriteBody {
110            content_block_id: None,
111            name: &cb.name,
112            description: cb.description.as_deref(),
113            content: &cb.content,
114            tags: &cb.tags,
115            // Create is the one time braze-sync communicates an initial
116            // state to Braze. On update we omit state entirely — see the
117            // note on update_content_block.
118            state: Some(cb.state),
119        };
120        let req = self.post(&["content_blocks", "create"]).json(&body);
121        let resp: ContentBlockCreateResponse = self.send_json(req).await?;
122        Ok(resp.content_block_id)
123    }
124
125    /// Used both for body changes and for the `--archive-orphans` rename
126    /// (same id, `[ARCHIVED-...]` name).
127    ///
128    /// `state` is intentionally omitted from the request body. The
129    /// diff layer excludes it from `syncable_eq` (there is no state
130    /// field on `/content_blocks/info`, so we cannot read it back and
131    /// cannot compare it), and the README documents it as a local-only
132    /// field. Forwarding `cb.state` here would let local edits leak
133    /// into Braze piggyback-style whenever another field changed, and
134    /// could silently overwrite a real remote state that braze-sync
135    /// has no way to observe — the same "infinite drift" trap the
136    /// honest-orphan design exists to avoid. Leaving state off makes
137    /// the wire-level behavior match the documented semantics.
138    pub async fn update_content_block(
139        &self,
140        id: &str,
141        cb: &ContentBlock,
142    ) -> Result<(), BrazeApiError> {
143        let body = ContentBlockWriteBody {
144            content_block_id: Some(id),
145            name: &cb.name,
146            description: cb.description.as_deref(),
147            content: &cb.content,
148            tags: &cb.tags,
149            state: None,
150        };
151        let req = self.post(&["content_blocks", "update"]).json(&body);
152        self.send_ok(req).await
153    }
154}
155
156#[derive(Debug, Deserialize)]
157struct ContentBlockListResponse {
158    #[serde(default)]
159    content_blocks: Vec<ContentBlockListEntry>,
160}
161
162#[derive(Debug, Deserialize)]
163struct ContentBlockListEntry {
164    content_block_id: String,
165    name: String,
166}
167
168#[derive(Debug, Deserialize)]
169struct ContentBlockInfoResponse {
170    #[serde(default)]
171    name: String,
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    description: Option<String>,
174    #[serde(default)]
175    content: String,
176    #[serde(default)]
177    tags: Vec<String>,
178    #[serde(default)]
179    message: Option<String>,
180}
181
182/// Wire body shared by `/content_blocks/create` and `.../update`. Both
183/// endpoints are replace-all on the fields serialized here: `tags` is
184/// always sent (an empty array drops every tag server-side) and
185/// `content` overwrites the current body. `description` is sent when
186/// `Some` (including `Some("")` — see `diff::content_block::desc_eq`
187/// for why empty-string is semantically equivalent to no description
188/// at diff time but still goes over the wire if present locally).
189/// `state` is the one field we intentionally do NOT round-trip on
190/// update — see the doc comment on `update_content_block`.
191#[derive(Serialize)]
192struct ContentBlockWriteBody<'a> {
193    #[serde(skip_serializing_if = "Option::is_none")]
194    content_block_id: Option<&'a str>,
195    name: &'a str,
196    #[serde(skip_serializing_if = "Option::is_none")]
197    description: Option<&'a str>,
198    content: &'a str,
199    tags: &'a [String],
200    #[serde(skip_serializing_if = "Option::is_none")]
201    state: Option<ContentBlockState>,
202}
203
204#[derive(Debug, Deserialize)]
205struct ContentBlockCreateResponse {
206    content_block_id: String,
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use crate::braze::test_client as make_client;
213    use reqwest::StatusCode;
214    use serde_json::json;
215    use wiremock::matchers::{
216        body_json, header, method, path, query_param, query_param_is_missing,
217    };
218    use wiremock::{Mock, MockServer, ResponseTemplate};
219
220    #[tokio::test]
221    async fn list_happy_path() {
222        let server = MockServer::start().await;
223        Mock::given(method("GET"))
224            .and(path("/content_blocks/list"))
225            .and(header("authorization", "Bearer test-key"))
226            .and(query_param("limit", "1000"))
227            .and(query_param_is_missing("offset"))
228            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
229                "content_blocks": [
230                    {"content_block_id": "id-1", "name": "promo"},
231                    {"content_block_id": "id-2", "name": "header"}
232                ],
233                "message": "success"
234            })))
235            .mount(&server)
236            .await;
237
238        let client = make_client(&server);
239        let summaries = client.list_content_blocks().await.unwrap();
240        assert_eq!(summaries.len(), 2);
241        assert_eq!(summaries[0].content_block_id, "id-1");
242        assert_eq!(summaries[0].name, "promo");
243    }
244
245    #[tokio::test]
246    async fn list_empty_array() {
247        let server = MockServer::start().await;
248        Mock::given(method("GET"))
249            .and(path("/content_blocks/list"))
250            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"content_blocks": []})))
251            .mount(&server)
252            .await;
253        let client = make_client(&server);
254        assert!(client.list_content_blocks().await.unwrap().is_empty());
255    }
256
257    #[tokio::test]
258    async fn list_ignores_unknown_fields() {
259        let server = MockServer::start().await;
260        Mock::given(method("GET"))
261            .and(path("/content_blocks/list"))
262            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
263                "content_blocks": [{
264                    "content_block_id": "id-1",
265                    "name": "promo",
266                    "content_type": "html",
267                    "liquid_tag": "{{content_blocks.${promo}}}",
268                    "future_metadata": {"foo": "bar"}
269                }]
270            })))
271            .mount(&server)
272            .await;
273        let client = make_client(&server);
274        let summaries = client.list_content_blocks().await.unwrap();
275        assert_eq!(summaries.len(), 1);
276        assert_eq!(summaries[0].name, "promo");
277    }
278
279    #[tokio::test]
280    async fn list_unauthorized() {
281        let server = MockServer::start().await;
282        Mock::given(method("GET"))
283            .and(path("/content_blocks/list"))
284            .respond_with(ResponseTemplate::new(401).set_body_string("invalid"))
285            .mount(&server)
286            .await;
287        let client = make_client(&server);
288        let err = client.list_content_blocks().await.unwrap_err();
289        assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
290    }
291
292    #[tokio::test]
293    async fn info_happy_path() {
294        let server = MockServer::start().await;
295        Mock::given(method("GET"))
296            .and(path("/content_blocks/info"))
297            .and(query_param("content_block_id", "id-1"))
298            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
299                "content_block_id": "id-1",
300                "name": "promo",
301                "description": "Promo banner",
302                "content": "Hello {{ user.${first_name} }}",
303                "tags": ["pr", "dialog"],
304                "content_type": "html",
305                "message": "success"
306            })))
307            .mount(&server)
308            .await;
309
310        let client = make_client(&server);
311        let cb = client.get_content_block("id-1").await.unwrap();
312        assert_eq!(cb.name, "promo");
313        assert_eq!(cb.description.as_deref(), Some("Promo banner"));
314        assert_eq!(cb.content, "Hello {{ user.${first_name} }}");
315        assert_eq!(cb.tags, vec!["pr".to_string(), "dialog".to_string()]);
316        // Braze does not return state; we default to Active.
317        assert_eq!(cb.state, ContentBlockState::Active);
318    }
319
320    #[tokio::test]
321    async fn info_with_unrecognised_error_message_surfaces_as_unexpected() {
322        // Regression guard: before this change, any non-"success"
323        // message was blanket-remapped to NotFound, which would silently
324        // mask a real server-side failure as a missing id. The classifier
325        // now only remaps known not-found phrases, so a novel message
326        // has to come back as `UnexpectedApiMessage`.
327        let server = MockServer::start().await;
328        Mock::given(method("GET"))
329            .and(path("/content_blocks/info"))
330            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
331                "message": "Internal server hiccup, please retry"
332            })))
333            .mount(&server)
334            .await;
335        let client = make_client(&server);
336        let err = client.get_content_block("some-id").await.unwrap_err();
337        match err {
338            BrazeApiError::UnexpectedApiMessage { endpoint, message } => {
339                assert_eq!(endpoint, "/content_blocks/info");
340                assert!(
341                    message.contains("Internal server hiccup"),
342                    "message not preserved verbatim: {message}"
343                );
344            }
345            other => panic!("expected UnexpectedApiMessage, got {other:?}"),
346        }
347    }
348
349    #[tokio::test]
350    async fn info_with_unsuccessful_message_is_not_found() {
351        let server = MockServer::start().await;
352        Mock::given(method("GET"))
353            .and(path("/content_blocks/info"))
354            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
355                "message": "No content block with id 'missing' found"
356            })))
357            .mount(&server)
358            .await;
359        let client = make_client(&server);
360        let err = client.get_content_block("missing").await.unwrap_err();
361        match err {
362            BrazeApiError::NotFound { resource } => assert!(resource.contains("missing")),
363            other => panic!("expected NotFound, got {other:?}"),
364        }
365    }
366
367    #[tokio::test]
368    async fn create_sends_correct_body_and_returns_id() {
369        let server = MockServer::start().await;
370        Mock::given(method("POST"))
371            .and(path("/content_blocks/create"))
372            .and(header("authorization", "Bearer test-key"))
373            .and(body_json(json!({
374                "name": "promo",
375                "description": "Promo banner",
376                "content": "Hello",
377                "tags": ["pr"],
378                "state": "active"
379            })))
380            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
381                "content_block_id": "new-id-123",
382                "message": "success"
383            })))
384            .mount(&server)
385            .await;
386
387        let client = make_client(&server);
388        let cb = ContentBlock {
389            name: "promo".into(),
390            description: Some("Promo banner".into()),
391            content: "Hello".into(),
392            tags: vec!["pr".into()],
393            state: ContentBlockState::Active,
394        };
395        let id = client.create_content_block(&cb).await.unwrap();
396        assert_eq!(id, "new-id-123");
397    }
398
399    #[tokio::test]
400    async fn create_omits_description_when_none() {
401        let server = MockServer::start().await;
402        Mock::given(method("POST"))
403            .and(path("/content_blocks/create"))
404            .and(body_json(json!({
405                "name": "minimal",
406                "content": "x",
407                "tags": [],
408                "state": "active"
409            })))
410            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
411                "content_block_id": "id-min"
412            })))
413            .mount(&server)
414            .await;
415        let client = make_client(&server);
416        let cb = ContentBlock {
417            name: "minimal".into(),
418            description: None,
419            content: "x".into(),
420            tags: vec![],
421            state: ContentBlockState::Active,
422        };
423        client.create_content_block(&cb).await.unwrap();
424    }
425
426    #[tokio::test]
427    async fn create_forwards_draft_state_to_request_body() {
428        // Counterpart to `update_sends_id_in_body_and_omits_state`: on
429        // create, state IS sent. The only difference between Active and
430        // Draft round-trips is the body_json matcher, so pinning Draft
431        // here locks in both serde variants going over the wire.
432        let server = MockServer::start().await;
433        Mock::given(method("POST"))
434            .and(path("/content_blocks/create"))
435            .and(body_json(json!({
436                "name": "wip",
437                "content": "draft body",
438                "tags": [],
439                "state": "draft"
440            })))
441            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
442                "content_block_id": "id-wip"
443            })))
444            .expect(1)
445            .mount(&server)
446            .await;
447        let client = make_client(&server);
448        let cb = ContentBlock {
449            name: "wip".into(),
450            description: None,
451            content: "draft body".into(),
452            tags: vec![],
453            state: ContentBlockState::Draft,
454        };
455        client.create_content_block(&cb).await.unwrap();
456    }
457
458    #[tokio::test]
459    async fn update_sends_id_in_body_and_omits_state() {
460        // Pins two invariants: the update body carries
461        // `content_block_id` (so Braze knows which block to modify),
462        // and it does NOT carry a `state` field. State is local-only
463        // per the README and `diff::content_block::syncable_eq`;
464        // sending it here would let a local `state: draft` silently
465        // overwrite the remote whenever another field happened to
466        // change.
467        let server = MockServer::start().await;
468        Mock::given(method("POST"))
469            .and(path("/content_blocks/update"))
470            .and(body_json(json!({
471                "content_block_id": "id-1",
472                "name": "promo",
473                "content": "Updated body",
474                "tags": []
475            })))
476            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"message": "success"})))
477            .mount(&server)
478            .await;
479
480        let client = make_client(&server);
481        // Deliberately pick Draft here: if the client still forwarded
482        // state on update, `body_json` above would fail to match
483        // because the body would carry `"state": "draft"`.
484        let cb = ContentBlock {
485            name: "promo".into(),
486            description: None,
487            content: "Updated body".into(),
488            tags: vec![],
489            state: ContentBlockState::Draft,
490        };
491        client.update_content_block("id-1", &cb).await.unwrap();
492    }
493
494    #[tokio::test]
495    async fn update_unauthorized_propagates() {
496        let server = MockServer::start().await;
497        Mock::given(method("POST"))
498            .and(path("/content_blocks/update"))
499            .respond_with(ResponseTemplate::new(401).set_body_string("invalid"))
500            .mount(&server)
501            .await;
502        let client = make_client(&server);
503        let cb = ContentBlock {
504            name: "x".into(),
505            description: None,
506            content: String::new(),
507            tags: vec![],
508            state: ContentBlockState::Active,
509        };
510        let err = client.update_content_block("id", &cb).await.unwrap_err();
511        assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
512    }
513
514    #[tokio::test]
515    async fn update_server_error_is_http() {
516        let server = MockServer::start().await;
517        Mock::given(method("POST"))
518            .and(path("/content_blocks/update"))
519            .respond_with(ResponseTemplate::new(500).set_body_string("oops"))
520            .mount(&server)
521            .await;
522        let client = make_client(&server);
523        let cb = ContentBlock {
524            name: "x".into(),
525            description: None,
526            content: String::new(),
527            tags: vec![],
528            state: ContentBlockState::Active,
529        };
530        let err = client.update_content_block("id", &cb).await.unwrap_err();
531        match err {
532            BrazeApiError::Http { status, body } => {
533                assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
534                assert!(body.contains("oops"));
535            }
536            other => panic!("expected Http, got {other:?}"),
537        }
538    }
539
540    #[tokio::test]
541    async fn list_short_page_is_treated_as_complete() {
542        let server = MockServer::start().await;
543        Mock::given(method("GET"))
544            .and(path("/content_blocks/list"))
545            .and(query_param_is_missing("offset"))
546            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
547                "content_blocks": [
548                    {"content_block_id": "id-1", "name": "a"},
549                    {"content_block_id": "id-2", "name": "b"}
550                ]
551            })))
552            .mount(&server)
553            .await;
554        let client = make_client(&server);
555        let summaries = client.list_content_blocks().await.unwrap();
556        assert_eq!(summaries.len(), 2);
557    }
558
559    #[tokio::test]
560    async fn list_offset_pagination_across_three_pages() {
561        let server = MockServer::start().await;
562        let page1: Vec<serde_json::Value> = (0..1000)
563            .map(|i| {
564                json!({
565                    "content_block_id": format!("id-p1-{i}"),
566                    "name": format!("p1_{i}")
567                })
568            })
569            .collect();
570        let page2: Vec<serde_json::Value> = (0..1000)
571            .map(|i| {
572                json!({
573                    "content_block_id": format!("id-p2-{i}"),
574                    "name": format!("p2_{i}")
575                })
576            })
577            .collect();
578        let page3: Vec<serde_json::Value> = (0..234)
579            .map(|i| {
580                json!({
581                    "content_block_id": format!("id-p3-{i}"),
582                    "name": format!("p3_{i}")
583                })
584            })
585            .collect();
586        Mock::given(method("GET"))
587            .and(path("/content_blocks/list"))
588            .and(query_param("offset", "2000"))
589            .respond_with(
590                ResponseTemplate::new(200).set_body_json(json!({ "content_blocks": page3 })),
591            )
592            .mount(&server)
593            .await;
594        Mock::given(method("GET"))
595            .and(path("/content_blocks/list"))
596            .and(query_param("offset", "1000"))
597            .respond_with(
598                ResponseTemplate::new(200).set_body_json(json!({ "content_blocks": page2 })),
599            )
600            .mount(&server)
601            .await;
602        Mock::given(method("GET"))
603            .and(path("/content_blocks/list"))
604            .and(query_param_is_missing("offset"))
605            .respond_with(
606                ResponseTemplate::new(200).set_body_json(json!({ "content_blocks": page1 })),
607            )
608            .mount(&server)
609            .await;
610
611        let client = make_client(&server);
612        let summaries = client.list_content_blocks().await.unwrap();
613        assert_eq!(summaries.len(), 2234);
614        assert_eq!(summaries[0].name, "p1_0");
615        assert_eq!(summaries[999].name, "p1_999");
616        assert_eq!(summaries[1000].name, "p2_0");
617        assert_eq!(summaries[2233].name, "p3_233");
618    }
619
620    #[tokio::test]
621    async fn list_errors_on_duplicate_name_in_response() {
622        // Regression guard: if Braze ever violates its own name-uniqueness
623        // contract, the BTreeMap-based name→id index in
624        // `diff::compute_content_block_plan` would silently keep only
625        // the last id for a duplicate pair, hiding one of the two blocks
626        // from every subsequent list/update/archive op. Fail loud instead.
627        let server = MockServer::start().await;
628        Mock::given(method("GET"))
629            .and(path("/content_blocks/list"))
630            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
631                "content_blocks": [
632                    {"content_block_id": "id-a", "name": "dup"},
633                    {"content_block_id": "id-b", "name": "dup"}
634                ]
635            })))
636            .mount(&server)
637            .await;
638        let client = make_client(&server);
639        let err = client.list_content_blocks().await.unwrap_err();
640        match err {
641            BrazeApiError::DuplicateNameInListResponse { endpoint, name } => {
642                assert_eq!(endpoint, "/content_blocks/list");
643                assert_eq!(name, "dup");
644            }
645            other => panic!("expected DuplicateNameInListResponse, got {other:?}"),
646        }
647    }
648
649    #[tokio::test]
650    async fn list_retries_on_429_then_succeeds() {
651        let server = MockServer::start().await;
652        Mock::given(method("GET"))
653            .and(path("/content_blocks/list"))
654            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
655                "content_blocks": [{"content_block_id": "id-x", "name": "x"}]
656            })))
657            .mount(&server)
658            .await;
659        Mock::given(method("GET"))
660            .and(path("/content_blocks/list"))
661            .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
662            .up_to_n_times(1)
663            .mount(&server)
664            .await;
665        let client = make_client(&server);
666        let summaries = client.list_content_blocks().await.unwrap();
667        assert_eq!(summaries.len(), 1);
668        assert_eq!(summaries[0].name, "x");
669    }
670}