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::{classify_info_message, BrazeClient, InfoMessageClass};
10use crate::resource::{ContentBlock, ContentBlockState};
11use serde::{Deserialize, Serialize};
12
13const LIST_LIMIT: u32 = 100;
14
15#[derive(Debug, Clone, PartialEq)]
16pub struct ContentBlockSummary {
17    pub content_block_id: String,
18    pub name: String,
19}
20
21impl BrazeClient {
22    /// Returns one page of up to [`LIST_LIMIT`] summaries. Hard-errors
23    /// rather than silently truncating: see `PaginationNotImplemented`.
24    pub async fn list_content_blocks(&self) -> Result<Vec<ContentBlockSummary>, BrazeApiError> {
25        let req = self
26            .get(&["content_blocks", "list"])
27            .query(&[("limit", LIST_LIMIT.to_string())]);
28        let resp: ContentBlockListResponse = self.send_json(req).await?;
29        let returned = resp.content_blocks.len();
30
31        // Fail closed when the page is or might be truncated. The
32        // ambiguous case (full page, no `count`) is treated as truncated
33        // because we'd rather refuse a workspace that happens to have
34        // exactly LIST_LIMIT blocks than let apply create duplicates of
35        // page-2 blocks in a workspace with LIST_LIMIT + N.
36        // The remaining `_ => None` arm also covers `None if returned <
37        // LIST_LIMIT`: a short page with no `count` is trusted as the
38        // full workspace because every paginated API we know of returns
39        // exactly `limit` when more pages exist. If Braze ever returns a
40        // soft-filtered short page (e.g. tombstoned entries hidden
41        // server-side), that assumption would silently truncate — worth
42        // revisiting in Phase C alongside real pagination.
43        let truncation_detail: Option<String> = match resp.count {
44            Some(total) if total > returned => Some(format!("got {returned} of {total} results")),
45            None if returned >= LIST_LIMIT as usize => Some(format!(
46                "got a full page of {returned} result(s) with no total reported; \
47                 cannot verify whether more exist"
48            )),
49            _ => None,
50        };
51        if let Some(detail) = truncation_detail {
52            return Err(BrazeApiError::PaginationNotImplemented {
53                endpoint: "/content_blocks/list",
54                detail,
55            });
56        }
57
58        // Duplicate names would collapse the name→id index in
59        // `diff::compute_content_block_plan`, making one of a pair
60        // invisible to every subsequent list/update/archive op. Braze
61        // is expected to enforce uniqueness, so this is a loud contract
62        // violation, not a recoverable condition.
63        let mut seen: std::collections::HashSet<&str> =
64            std::collections::HashSet::with_capacity(resp.content_blocks.len());
65        for entry in &resp.content_blocks {
66            if !seen.insert(entry.name.as_str()) {
67                return Err(BrazeApiError::DuplicateNameInListResponse {
68                    endpoint: "/content_blocks/list",
69                    name: entry.name.clone(),
70                });
71            }
72        }
73
74        Ok(resp
75            .content_blocks
76            .into_iter()
77            .map(|w| ContentBlockSummary {
78                content_block_id: w.content_block_id,
79                name: w.name,
80            })
81            .collect())
82    }
83
84    /// Braze returns 200 with a non-success `message` field for unknown
85    /// ids instead of a 404, so we need to discriminate here rather than
86    /// relying on HTTP status. Recognised not-found phrases remap to
87    /// `NotFound` so callers can branch cleanly; any other non-"success"
88    /// message surfaces verbatim as `UnexpectedApiMessage` so a real
89    /// failure is not silently swallowed. The wire shapes are ASSUMED
90    /// per IMPLEMENTATION.md §8.3 — a blanket "non-success → NotFound"
91    /// rule would misclassify every future surprise as a missing id.
92    pub async fn get_content_block(&self, id: &str) -> Result<ContentBlock, BrazeApiError> {
93        let req = self
94            .get(&["content_blocks", "info"])
95            .query(&[("content_block_id", id)]);
96        let wire: ContentBlockInfoResponse = self.send_json(req).await?;
97        match classify_info_message(wire.message.as_deref(), "no content block") {
98            InfoMessageClass::Success => {}
99            InfoMessageClass::NotFound => {
100                return Err(BrazeApiError::NotFound {
101                    resource: format!("content_block id '{id}'"),
102                });
103            }
104            InfoMessageClass::Unexpected(message) => {
105                return Err(BrazeApiError::UnexpectedApiMessage {
106                    endpoint: "/content_blocks/info",
107                    message,
108                });
109            }
110        }
111        Ok(ContentBlock {
112            name: wire.name,
113            description: wire.description,
114            content: wire.content,
115            tags: wire.tags,
116            // Braze /info has no state field; default keeps round-trips
117            // stable. See diff/content_block.rs syncable_eq for why this
118            // can't drift the diff layer.
119            state: ContentBlockState::Active,
120        })
121    }
122
123    pub async fn create_content_block(&self, cb: &ContentBlock) -> Result<String, BrazeApiError> {
124        let body = ContentBlockWriteBody {
125            content_block_id: None,
126            name: &cb.name,
127            description: cb.description.as_deref(),
128            content: &cb.content,
129            tags: &cb.tags,
130            // Create is the one time braze-sync communicates an initial
131            // state to Braze. On update we omit state entirely — see the
132            // note on update_content_block.
133            state: Some(cb.state),
134        };
135        let req = self.post(&["content_blocks", "create"]).json(&body);
136        let resp: ContentBlockCreateResponse = self.send_json(req).await?;
137        Ok(resp.content_block_id)
138    }
139
140    /// Used both for body changes and for the `--archive-orphans` rename
141    /// (same id, `[ARCHIVED-...]` name).
142    ///
143    /// `state` is intentionally omitted from the request body. The
144    /// diff layer excludes it from `syncable_eq` (there is no state
145    /// field on `/content_blocks/info`, so we cannot read it back and
146    /// cannot compare it), and the README documents it as a local-only
147    /// field. Forwarding `cb.state` here would let local edits leak
148    /// into Braze piggyback-style whenever another field changed, and
149    /// could silently overwrite a real remote state that braze-sync
150    /// has no way to observe — the same "infinite drift" trap the
151    /// honest-orphan design exists to avoid. Leaving state off makes
152    /// the wire-level behavior match the documented semantics.
153    pub async fn update_content_block(
154        &self,
155        id: &str,
156        cb: &ContentBlock,
157    ) -> Result<(), BrazeApiError> {
158        let body = ContentBlockWriteBody {
159            content_block_id: Some(id),
160            name: &cb.name,
161            description: cb.description.as_deref(),
162            content: &cb.content,
163            tags: &cb.tags,
164            state: None,
165        };
166        let req = self.post(&["content_blocks", "update"]).json(&body);
167        self.send_ok(req).await
168    }
169}
170
171#[derive(Debug, Deserialize)]
172struct ContentBlockListResponse {
173    #[serde(default)]
174    content_blocks: Vec<ContentBlockListEntry>,
175    #[serde(default)]
176    count: Option<usize>,
177}
178
179#[derive(Debug, Deserialize)]
180struct ContentBlockListEntry {
181    content_block_id: String,
182    name: String,
183}
184
185#[derive(Debug, Deserialize)]
186struct ContentBlockInfoResponse {
187    #[serde(default)]
188    name: String,
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    description: Option<String>,
191    #[serde(default)]
192    content: String,
193    #[serde(default)]
194    tags: Vec<String>,
195    #[serde(default)]
196    message: Option<String>,
197}
198
199/// Wire body shared by `/content_blocks/create` and `.../update`. Both
200/// endpoints are replace-all on the fields serialized here: `tags` is
201/// always sent (an empty array drops every tag server-side) and
202/// `content` overwrites the current body. `description` is sent when
203/// `Some` (including `Some("")` — see `diff::content_block::desc_eq`
204/// for why empty-string is semantically equivalent to no description
205/// at diff time but still goes over the wire if present locally).
206/// `state` is the one field we intentionally do NOT round-trip on
207/// update — see the doc comment on `update_content_block`.
208#[derive(Serialize)]
209struct ContentBlockWriteBody<'a> {
210    #[serde(skip_serializing_if = "Option::is_none")]
211    content_block_id: Option<&'a str>,
212    name: &'a str,
213    #[serde(skip_serializing_if = "Option::is_none")]
214    description: Option<&'a str>,
215    content: &'a str,
216    tags: &'a [String],
217    #[serde(skip_serializing_if = "Option::is_none")]
218    state: Option<ContentBlockState>,
219}
220
221#[derive(Debug, Deserialize)]
222struct ContentBlockCreateResponse {
223    content_block_id: String,
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use crate::braze::test_client as make_client;
230    use reqwest::StatusCode;
231    use serde_json::json;
232    use wiremock::matchers::{body_json, header, method, path, query_param};
233    use wiremock::{Mock, MockServer, ResponseTemplate};
234
235    #[tokio::test]
236    async fn list_happy_path() {
237        let server = MockServer::start().await;
238        Mock::given(method("GET"))
239            .and(path("/content_blocks/list"))
240            .and(header("authorization", "Bearer test-key"))
241            .and(query_param("limit", "100"))
242            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
243                "count": 2,
244                "content_blocks": [
245                    {"content_block_id": "id-1", "name": "promo"},
246                    {"content_block_id": "id-2", "name": "header"}
247                ],
248                "message": "success"
249            })))
250            .mount(&server)
251            .await;
252
253        let client = make_client(&server);
254        let summaries = client.list_content_blocks().await.unwrap();
255        assert_eq!(summaries.len(), 2);
256        assert_eq!(summaries[0].content_block_id, "id-1");
257        assert_eq!(summaries[0].name, "promo");
258    }
259
260    #[tokio::test]
261    async fn list_empty_array() {
262        let server = MockServer::start().await;
263        Mock::given(method("GET"))
264            .and(path("/content_blocks/list"))
265            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"content_blocks": []})))
266            .mount(&server)
267            .await;
268        let client = make_client(&server);
269        assert!(client.list_content_blocks().await.unwrap().is_empty());
270    }
271
272    #[tokio::test]
273    async fn list_ignores_unknown_fields() {
274        let server = MockServer::start().await;
275        Mock::given(method("GET"))
276            .and(path("/content_blocks/list"))
277            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
278                "content_blocks": [{
279                    "content_block_id": "id-1",
280                    "name": "promo",
281                    "content_type": "html",
282                    "liquid_tag": "{{content_blocks.${promo}}}",
283                    "future_metadata": {"foo": "bar"}
284                }]
285            })))
286            .mount(&server)
287            .await;
288        let client = make_client(&server);
289        let summaries = client.list_content_blocks().await.unwrap();
290        assert_eq!(summaries.len(), 1);
291        assert_eq!(summaries[0].name, "promo");
292    }
293
294    #[tokio::test]
295    async fn list_unauthorized() {
296        let server = MockServer::start().await;
297        Mock::given(method("GET"))
298            .and(path("/content_blocks/list"))
299            .respond_with(ResponseTemplate::new(401).set_body_string("invalid"))
300            .mount(&server)
301            .await;
302        let client = make_client(&server);
303        let err = client.list_content_blocks().await.unwrap_err();
304        assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
305    }
306
307    #[tokio::test]
308    async fn info_happy_path() {
309        let server = MockServer::start().await;
310        Mock::given(method("GET"))
311            .and(path("/content_blocks/info"))
312            .and(query_param("content_block_id", "id-1"))
313            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
314                "content_block_id": "id-1",
315                "name": "promo",
316                "description": "Promo banner",
317                "content": "Hello {{ user.${first_name} }}",
318                "tags": ["pr", "dialog"],
319                "content_type": "html",
320                "message": "success"
321            })))
322            .mount(&server)
323            .await;
324
325        let client = make_client(&server);
326        let cb = client.get_content_block("id-1").await.unwrap();
327        assert_eq!(cb.name, "promo");
328        assert_eq!(cb.description.as_deref(), Some("Promo banner"));
329        assert_eq!(cb.content, "Hello {{ user.${first_name} }}");
330        assert_eq!(cb.tags, vec!["pr".to_string(), "dialog".to_string()]);
331        // Braze does not return state; we default to Active.
332        assert_eq!(cb.state, ContentBlockState::Active);
333    }
334
335    #[tokio::test]
336    async fn info_with_unrecognised_error_message_surfaces_as_unexpected() {
337        // Regression guard: before this change, any non-"success"
338        // message was blanket-remapped to NotFound, which would silently
339        // mask a real server-side failure as a missing id. The classifier
340        // now only remaps known not-found phrases, so a novel message
341        // has to come back as `UnexpectedApiMessage`.
342        let server = MockServer::start().await;
343        Mock::given(method("GET"))
344            .and(path("/content_blocks/info"))
345            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
346                "message": "Internal server hiccup, please retry"
347            })))
348            .mount(&server)
349            .await;
350        let client = make_client(&server);
351        let err = client.get_content_block("some-id").await.unwrap_err();
352        match err {
353            BrazeApiError::UnexpectedApiMessage { endpoint, message } => {
354                assert_eq!(endpoint, "/content_blocks/info");
355                assert!(
356                    message.contains("Internal server hiccup"),
357                    "message not preserved verbatim: {message}"
358                );
359            }
360            other => panic!("expected UnexpectedApiMessage, got {other:?}"),
361        }
362    }
363
364    #[tokio::test]
365    async fn info_with_unsuccessful_message_is_not_found() {
366        let server = MockServer::start().await;
367        Mock::given(method("GET"))
368            .and(path("/content_blocks/info"))
369            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
370                "message": "No content block with id 'missing' found"
371            })))
372            .mount(&server)
373            .await;
374        let client = make_client(&server);
375        let err = client.get_content_block("missing").await.unwrap_err();
376        match err {
377            BrazeApiError::NotFound { resource } => assert!(resource.contains("missing")),
378            other => panic!("expected NotFound, got {other:?}"),
379        }
380    }
381
382    #[tokio::test]
383    async fn create_sends_correct_body_and_returns_id() {
384        let server = MockServer::start().await;
385        Mock::given(method("POST"))
386            .and(path("/content_blocks/create"))
387            .and(header("authorization", "Bearer test-key"))
388            .and(body_json(json!({
389                "name": "promo",
390                "description": "Promo banner",
391                "content": "Hello",
392                "tags": ["pr"],
393                "state": "active"
394            })))
395            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
396                "content_block_id": "new-id-123",
397                "message": "success"
398            })))
399            .mount(&server)
400            .await;
401
402        let client = make_client(&server);
403        let cb = ContentBlock {
404            name: "promo".into(),
405            description: Some("Promo banner".into()),
406            content: "Hello".into(),
407            tags: vec!["pr".into()],
408            state: ContentBlockState::Active,
409        };
410        let id = client.create_content_block(&cb).await.unwrap();
411        assert_eq!(id, "new-id-123");
412    }
413
414    #[tokio::test]
415    async fn create_omits_description_when_none() {
416        let server = MockServer::start().await;
417        Mock::given(method("POST"))
418            .and(path("/content_blocks/create"))
419            .and(body_json(json!({
420                "name": "minimal",
421                "content": "x",
422                "tags": [],
423                "state": "active"
424            })))
425            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
426                "content_block_id": "id-min"
427            })))
428            .mount(&server)
429            .await;
430        let client = make_client(&server);
431        let cb = ContentBlock {
432            name: "minimal".into(),
433            description: None,
434            content: "x".into(),
435            tags: vec![],
436            state: ContentBlockState::Active,
437        };
438        client.create_content_block(&cb).await.unwrap();
439    }
440
441    #[tokio::test]
442    async fn create_forwards_draft_state_to_request_body() {
443        // Counterpart to `update_sends_id_in_body_and_omits_state`: on
444        // create, state IS sent. The only difference between Active and
445        // Draft round-trips is the body_json matcher, so pinning Draft
446        // here locks in both serde variants going over the wire.
447        let server = MockServer::start().await;
448        Mock::given(method("POST"))
449            .and(path("/content_blocks/create"))
450            .and(body_json(json!({
451                "name": "wip",
452                "content": "draft body",
453                "tags": [],
454                "state": "draft"
455            })))
456            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
457                "content_block_id": "id-wip"
458            })))
459            .expect(1)
460            .mount(&server)
461            .await;
462        let client = make_client(&server);
463        let cb = ContentBlock {
464            name: "wip".into(),
465            description: None,
466            content: "draft body".into(),
467            tags: vec![],
468            state: ContentBlockState::Draft,
469        };
470        client.create_content_block(&cb).await.unwrap();
471    }
472
473    #[tokio::test]
474    async fn update_sends_id_in_body_and_omits_state() {
475        // Pins two invariants: the update body carries
476        // `content_block_id` (so Braze knows which block to modify),
477        // and it does NOT carry a `state` field. State is local-only
478        // per the README and `diff::content_block::syncable_eq`;
479        // sending it here would let a local `state: draft` silently
480        // overwrite the remote whenever another field happened to
481        // change.
482        let server = MockServer::start().await;
483        Mock::given(method("POST"))
484            .and(path("/content_blocks/update"))
485            .and(body_json(json!({
486                "content_block_id": "id-1",
487                "name": "promo",
488                "content": "Updated body",
489                "tags": []
490            })))
491            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"message": "success"})))
492            .mount(&server)
493            .await;
494
495        let client = make_client(&server);
496        // Deliberately pick Draft here: if the client still forwarded
497        // state on update, `body_json` above would fail to match
498        // because the body would carry `"state": "draft"`.
499        let cb = ContentBlock {
500            name: "promo".into(),
501            description: None,
502            content: "Updated body".into(),
503            tags: vec![],
504            state: ContentBlockState::Draft,
505        };
506        client.update_content_block("id-1", &cb).await.unwrap();
507    }
508
509    #[tokio::test]
510    async fn update_unauthorized_propagates() {
511        let server = MockServer::start().await;
512        Mock::given(method("POST"))
513            .and(path("/content_blocks/update"))
514            .respond_with(ResponseTemplate::new(401).set_body_string("invalid"))
515            .mount(&server)
516            .await;
517        let client = make_client(&server);
518        let cb = ContentBlock {
519            name: "x".into(),
520            description: None,
521            content: String::new(),
522            tags: vec![],
523            state: ContentBlockState::Active,
524        };
525        let err = client.update_content_block("id", &cb).await.unwrap_err();
526        assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
527    }
528
529    #[tokio::test]
530    async fn update_server_error_is_http() {
531        let server = MockServer::start().await;
532        Mock::given(method("POST"))
533            .and(path("/content_blocks/update"))
534            .respond_with(ResponseTemplate::new(500).set_body_string("oops"))
535            .mount(&server)
536            .await;
537        let client = make_client(&server);
538        let cb = ContentBlock {
539            name: "x".into(),
540            description: None,
541            content: String::new(),
542            tags: vec![],
543            state: ContentBlockState::Active,
544        };
545        let err = client.update_content_block("id", &cb).await.unwrap_err();
546        match err {
547            BrazeApiError::Http { status, body } => {
548                assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
549                assert!(body.contains("oops"));
550            }
551            other => panic!("expected Http, got {other:?}"),
552        }
553    }
554
555    #[tokio::test]
556    async fn list_errors_when_count_exceeds_returned() {
557        let server = MockServer::start().await;
558        let entries: Vec<serde_json::Value> = (0..100)
559            .map(|i| {
560                json!({
561                    "content_block_id": format!("id-{i}"),
562                    "name": format!("block-{i}")
563                })
564            })
565            .collect();
566        Mock::given(method("GET"))
567            .and(path("/content_blocks/list"))
568            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
569                "count": 250,
570                "content_blocks": entries,
571                "message": "success"
572            })))
573            .mount(&server)
574            .await;
575        let client = make_client(&server);
576        let err = client.list_content_blocks().await.unwrap_err();
577        match err {
578            BrazeApiError::PaginationNotImplemented { endpoint, detail } => {
579                assert_eq!(endpoint, "/content_blocks/list");
580                assert!(detail.contains("100"), "detail: {detail}");
581                assert!(detail.contains("250"), "detail: {detail}");
582            }
583            other => panic!("expected PaginationNotImplemented, got {other:?}"),
584        }
585    }
586
587    #[tokio::test]
588    async fn list_errors_on_full_page_with_no_count_field() {
589        // Ambiguous case: 100 returned, no `count` to disambiguate.
590        // Fail closed rather than risk page-2 invisibility.
591        let server = MockServer::start().await;
592        let entries: Vec<serde_json::Value> = (0..100)
593            .map(|i| {
594                json!({
595                    "content_block_id": format!("id-{i}"),
596                    "name": format!("block-{i}")
597                })
598            })
599            .collect();
600        Mock::given(method("GET"))
601            .and(path("/content_blocks/list"))
602            .respond_with(
603                ResponseTemplate::new(200).set_body_json(json!({ "content_blocks": entries })),
604            )
605            .mount(&server)
606            .await;
607        let client = make_client(&server);
608        let err = client.list_content_blocks().await.unwrap_err();
609        assert!(
610            matches!(err, BrazeApiError::PaginationNotImplemented { .. }),
611            "got {err:?}"
612        );
613    }
614
615    #[tokio::test]
616    async fn list_short_page_with_no_count_is_trusted_as_complete() {
617        // Pins the `_ => None` arm of the truncation match for the
618        // non-empty-short-page-no-count case. `list_empty_array`
619        // covers the 0-entry flavour; this test nails down that a
620        // partial-but-under-LIMIT page without `count` is accepted
621        // as the full workspace. Matches the comment on
622        // `list_content_blocks` about every known paginated API
623        // returning exactly `limit` when more pages exist.
624        let server = MockServer::start().await;
625        Mock::given(method("GET"))
626            .and(path("/content_blocks/list"))
627            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
628                "content_blocks": [
629                    {"content_block_id": "id-1", "name": "a"},
630                    {"content_block_id": "id-2", "name": "b"}
631                ]
632            })))
633            .mount(&server)
634            .await;
635        let client = make_client(&server);
636        let summaries = client.list_content_blocks().await.unwrap();
637        assert_eq!(summaries.len(), 2);
638        assert_eq!(summaries[0].name, "a");
639        assert_eq!(summaries[1].name, "b");
640    }
641
642    #[tokio::test]
643    async fn list_succeeds_when_count_matches_full_page_exactly() {
644        // 100 returned + count: 100 → exact full workspace, definitely
645        // no more pages, must succeed.
646        let server = MockServer::start().await;
647        let entries: Vec<serde_json::Value> = (0..100)
648            .map(|i| {
649                json!({
650                    "content_block_id": format!("id-{i}"),
651                    "name": format!("block-{i}")
652                })
653            })
654            .collect();
655        Mock::given(method("GET"))
656            .and(path("/content_blocks/list"))
657            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
658                "count": 100,
659                "content_blocks": entries
660            })))
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(), 100);
666    }
667
668    #[tokio::test]
669    async fn list_errors_on_duplicate_name_in_response() {
670        // Regression guard: if Braze ever violates its own name-uniqueness
671        // contract, the BTreeMap-based name→id index in
672        // `diff::compute_content_block_plan` would silently keep only
673        // the last id for a duplicate pair, hiding one of the two blocks
674        // from every subsequent list/update/archive op. Fail loud instead.
675        let server = MockServer::start().await;
676        Mock::given(method("GET"))
677            .and(path("/content_blocks/list"))
678            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
679                "count": 2,
680                "content_blocks": [
681                    {"content_block_id": "id-a", "name": "dup"},
682                    {"content_block_id": "id-b", "name": "dup"}
683                ]
684            })))
685            .mount(&server)
686            .await;
687        let client = make_client(&server);
688        let err = client.list_content_blocks().await.unwrap_err();
689        match err {
690            BrazeApiError::DuplicateNameInListResponse { endpoint, name } => {
691                assert_eq!(endpoint, "/content_blocks/list");
692                assert_eq!(name, "dup");
693            }
694            other => panic!("expected DuplicateNameInListResponse, got {other:?}"),
695        }
696    }
697
698    #[tokio::test]
699    async fn list_retries_on_429_then_succeeds() {
700        let server = MockServer::start().await;
701        Mock::given(method("GET"))
702            .and(path("/content_blocks/list"))
703            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
704                "content_blocks": [{"content_block_id": "id-x", "name": "x"}]
705            })))
706            .mount(&server)
707            .await;
708        Mock::given(method("GET"))
709            .and(path("/content_blocks/list"))
710            .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
711            .up_to_n_times(1)
712            .mount(&server)
713            .await;
714        let client = make_client(&server);
715        let summaries = client.list_content_blocks().await.unwrap();
716        assert_eq!(summaries.len(), 1);
717        assert_eq!(summaries[0].name, "x");
718    }
719}