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::BrazeClient;
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 wire.classify_message() {
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/// Outcome of classifying the `message` field on a `/content_blocks/info`
200/// response. `NotFound` preserves the call-site branching contract; the
201/// `Unexpected` arm exists so an unknown message does not get silently
202/// folded into `NotFound` — see the doc comment on `get_content_block`.
203enum InfoMessageClass {
204    Success,
205    NotFound,
206    Unexpected(String),
207}
208
209impl ContentBlockInfoResponse {
210    fn classify_message(&self) -> InfoMessageClass {
211        let Some(raw) = self.message.as_deref() else {
212            return InfoMessageClass::Success;
213        };
214        let trimmed = raw.trim();
215        if trimmed.eq_ignore_ascii_case("success") {
216            return InfoMessageClass::Success;
217        }
218        let lower = trimmed.to_ascii_lowercase();
219        // Match the known not-found phrasings conservatively. Anything
220        // we don't recognise must NOT be treated as NotFound — that is
221        // the whole point of this classifier over the previous boolean
222        // check.
223        if lower.contains("not found")
224            || lower.contains("no content block")
225            || lower.contains("does not exist")
226        {
227            InfoMessageClass::NotFound
228        } else {
229            InfoMessageClass::Unexpected(raw.to_string())
230        }
231    }
232}
233
234/// Wire body shared by `/content_blocks/create` and `.../update`. Both
235/// endpoints are replace-all on the fields serialized here: `tags` is
236/// always sent (an empty array drops every tag server-side) and
237/// `content` overwrites the current body. `description` is sent when
238/// `Some` (including `Some("")` — see `diff::content_block::desc_eq`
239/// for why empty-string is semantically equivalent to no description
240/// at diff time but still goes over the wire if present locally).
241/// `state` is the one field we intentionally do NOT round-trip on
242/// update — see the doc comment on `update_content_block`.
243#[derive(Serialize)]
244struct ContentBlockWriteBody<'a> {
245    #[serde(skip_serializing_if = "Option::is_none")]
246    content_block_id: Option<&'a str>,
247    name: &'a str,
248    #[serde(skip_serializing_if = "Option::is_none")]
249    description: Option<&'a str>,
250    content: &'a str,
251    tags: &'a [String],
252    #[serde(skip_serializing_if = "Option::is_none")]
253    state: Option<ContentBlockState>,
254}
255
256#[derive(Debug, Deserialize)]
257struct ContentBlockCreateResponse {
258    content_block_id: String,
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use crate::braze::test_client as make_client;
265    use reqwest::StatusCode;
266    use serde_json::json;
267    use wiremock::matchers::{body_json, header, method, path, query_param};
268    use wiremock::{Mock, MockServer, ResponseTemplate};
269
270    #[tokio::test]
271    async fn list_happy_path() {
272        let server = MockServer::start().await;
273        Mock::given(method("GET"))
274            .and(path("/content_blocks/list"))
275            .and(header("authorization", "Bearer test-key"))
276            .and(query_param("limit", "100"))
277            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
278                "count": 2,
279                "content_blocks": [
280                    {"content_block_id": "id-1", "name": "promo"},
281                    {"content_block_id": "id-2", "name": "header"}
282                ],
283                "message": "success"
284            })))
285            .mount(&server)
286            .await;
287
288        let client = make_client(&server);
289        let summaries = client.list_content_blocks().await.unwrap();
290        assert_eq!(summaries.len(), 2);
291        assert_eq!(summaries[0].content_block_id, "id-1");
292        assert_eq!(summaries[0].name, "promo");
293    }
294
295    #[tokio::test]
296    async fn list_empty_array() {
297        let server = MockServer::start().await;
298        Mock::given(method("GET"))
299            .and(path("/content_blocks/list"))
300            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"content_blocks": []})))
301            .mount(&server)
302            .await;
303        let client = make_client(&server);
304        assert!(client.list_content_blocks().await.unwrap().is_empty());
305    }
306
307    #[tokio::test]
308    async fn list_ignores_unknown_fields() {
309        let server = MockServer::start().await;
310        Mock::given(method("GET"))
311            .and(path("/content_blocks/list"))
312            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
313                "content_blocks": [{
314                    "content_block_id": "id-1",
315                    "name": "promo",
316                    "content_type": "html",
317                    "liquid_tag": "{{content_blocks.${promo}}}",
318                    "future_metadata": {"foo": "bar"}
319                }]
320            })))
321            .mount(&server)
322            .await;
323        let client = make_client(&server);
324        let summaries = client.list_content_blocks().await.unwrap();
325        assert_eq!(summaries.len(), 1);
326        assert_eq!(summaries[0].name, "promo");
327    }
328
329    #[tokio::test]
330    async fn list_unauthorized() {
331        let server = MockServer::start().await;
332        Mock::given(method("GET"))
333            .and(path("/content_blocks/list"))
334            .respond_with(ResponseTemplate::new(401).set_body_string("invalid"))
335            .mount(&server)
336            .await;
337        let client = make_client(&server);
338        let err = client.list_content_blocks().await.unwrap_err();
339        assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
340    }
341
342    #[tokio::test]
343    async fn info_happy_path() {
344        let server = MockServer::start().await;
345        Mock::given(method("GET"))
346            .and(path("/content_blocks/info"))
347            .and(query_param("content_block_id", "id-1"))
348            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
349                "content_block_id": "id-1",
350                "name": "promo",
351                "description": "Promo banner",
352                "content": "Hello {{ user.${first_name} }}",
353                "tags": ["pr", "dialog"],
354                "content_type": "html",
355                "message": "success"
356            })))
357            .mount(&server)
358            .await;
359
360        let client = make_client(&server);
361        let cb = client.get_content_block("id-1").await.unwrap();
362        assert_eq!(cb.name, "promo");
363        assert_eq!(cb.description.as_deref(), Some("Promo banner"));
364        assert_eq!(cb.content, "Hello {{ user.${first_name} }}");
365        assert_eq!(cb.tags, vec!["pr".to_string(), "dialog".to_string()]);
366        // Braze does not return state; we default to Active.
367        assert_eq!(cb.state, ContentBlockState::Active);
368    }
369
370    #[tokio::test]
371    async fn info_with_unrecognised_error_message_surfaces_as_unexpected() {
372        // Regression guard: before this change, any non-"success"
373        // message was blanket-remapped to NotFound, which would silently
374        // mask a real server-side failure as a missing id. The classifier
375        // now only remaps known not-found phrases, so a novel message
376        // has to come back as `UnexpectedApiMessage`.
377        let server = MockServer::start().await;
378        Mock::given(method("GET"))
379            .and(path("/content_blocks/info"))
380            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
381                "message": "Internal server hiccup, please retry"
382            })))
383            .mount(&server)
384            .await;
385        let client = make_client(&server);
386        let err = client.get_content_block("some-id").await.unwrap_err();
387        match err {
388            BrazeApiError::UnexpectedApiMessage { endpoint, message } => {
389                assert_eq!(endpoint, "/content_blocks/info");
390                assert!(
391                    message.contains("Internal server hiccup"),
392                    "message not preserved verbatim: {message}"
393                );
394            }
395            other => panic!("expected UnexpectedApiMessage, got {other:?}"),
396        }
397    }
398
399    #[tokio::test]
400    async fn info_with_unsuccessful_message_is_not_found() {
401        let server = MockServer::start().await;
402        Mock::given(method("GET"))
403            .and(path("/content_blocks/info"))
404            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
405                "message": "No content block with id 'missing' found"
406            })))
407            .mount(&server)
408            .await;
409        let client = make_client(&server);
410        let err = client.get_content_block("missing").await.unwrap_err();
411        match err {
412            BrazeApiError::NotFound { resource } => assert!(resource.contains("missing")),
413            other => panic!("expected NotFound, got {other:?}"),
414        }
415    }
416
417    #[tokio::test]
418    async fn create_sends_correct_body_and_returns_id() {
419        let server = MockServer::start().await;
420        Mock::given(method("POST"))
421            .and(path("/content_blocks/create"))
422            .and(header("authorization", "Bearer test-key"))
423            .and(body_json(json!({
424                "name": "promo",
425                "description": "Promo banner",
426                "content": "Hello",
427                "tags": ["pr"],
428                "state": "active"
429            })))
430            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
431                "content_block_id": "new-id-123",
432                "message": "success"
433            })))
434            .mount(&server)
435            .await;
436
437        let client = make_client(&server);
438        let cb = ContentBlock {
439            name: "promo".into(),
440            description: Some("Promo banner".into()),
441            content: "Hello".into(),
442            tags: vec!["pr".into()],
443            state: ContentBlockState::Active,
444        };
445        let id = client.create_content_block(&cb).await.unwrap();
446        assert_eq!(id, "new-id-123");
447    }
448
449    #[tokio::test]
450    async fn create_omits_description_when_none() {
451        let server = MockServer::start().await;
452        Mock::given(method("POST"))
453            .and(path("/content_blocks/create"))
454            .and(body_json(json!({
455                "name": "minimal",
456                "content": "x",
457                "tags": [],
458                "state": "active"
459            })))
460            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
461                "content_block_id": "id-min"
462            })))
463            .mount(&server)
464            .await;
465        let client = make_client(&server);
466        let cb = ContentBlock {
467            name: "minimal".into(),
468            description: None,
469            content: "x".into(),
470            tags: vec![],
471            state: ContentBlockState::Active,
472        };
473        client.create_content_block(&cb).await.unwrap();
474    }
475
476    #[tokio::test]
477    async fn create_forwards_draft_state_to_request_body() {
478        // Counterpart to `update_sends_id_in_body_and_omits_state`: on
479        // create, state IS sent. The only difference between Active and
480        // Draft round-trips is the body_json matcher, so pinning Draft
481        // here locks in both serde variants going over the wire.
482        let server = MockServer::start().await;
483        Mock::given(method("POST"))
484            .and(path("/content_blocks/create"))
485            .and(body_json(json!({
486                "name": "wip",
487                "content": "draft body",
488                "tags": [],
489                "state": "draft"
490            })))
491            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
492                "content_block_id": "id-wip"
493            })))
494            .expect(1)
495            .mount(&server)
496            .await;
497        let client = make_client(&server);
498        let cb = ContentBlock {
499            name: "wip".into(),
500            description: None,
501            content: "draft body".into(),
502            tags: vec![],
503            state: ContentBlockState::Draft,
504        };
505        client.create_content_block(&cb).await.unwrap();
506    }
507
508    #[tokio::test]
509    async fn update_sends_id_in_body_and_omits_state() {
510        // Pins two invariants: the update body carries
511        // `content_block_id` (so Braze knows which block to modify),
512        // and it does NOT carry a `state` field. State is local-only
513        // per the README and `diff::content_block::syncable_eq`;
514        // sending it here would let a local `state: draft` silently
515        // overwrite the remote whenever another field happened to
516        // change.
517        let server = MockServer::start().await;
518        Mock::given(method("POST"))
519            .and(path("/content_blocks/update"))
520            .and(body_json(json!({
521                "content_block_id": "id-1",
522                "name": "promo",
523                "content": "Updated body",
524                "tags": []
525            })))
526            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"message": "success"})))
527            .mount(&server)
528            .await;
529
530        let client = make_client(&server);
531        // Deliberately pick Draft here: if the client still forwarded
532        // state on update, `body_json` above would fail to match
533        // because the body would carry `"state": "draft"`.
534        let cb = ContentBlock {
535            name: "promo".into(),
536            description: None,
537            content: "Updated body".into(),
538            tags: vec![],
539            state: ContentBlockState::Draft,
540        };
541        client.update_content_block("id-1", &cb).await.unwrap();
542    }
543
544    #[tokio::test]
545    async fn update_unauthorized_propagates() {
546        let server = MockServer::start().await;
547        Mock::given(method("POST"))
548            .and(path("/content_blocks/update"))
549            .respond_with(ResponseTemplate::new(401).set_body_string("invalid"))
550            .mount(&server)
551            .await;
552        let client = make_client(&server);
553        let cb = ContentBlock {
554            name: "x".into(),
555            description: None,
556            content: String::new(),
557            tags: vec![],
558            state: ContentBlockState::Active,
559        };
560        let err = client.update_content_block("id", &cb).await.unwrap_err();
561        assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
562    }
563
564    #[tokio::test]
565    async fn update_server_error_is_http() {
566        let server = MockServer::start().await;
567        Mock::given(method("POST"))
568            .and(path("/content_blocks/update"))
569            .respond_with(ResponseTemplate::new(500).set_body_string("oops"))
570            .mount(&server)
571            .await;
572        let client = make_client(&server);
573        let cb = ContentBlock {
574            name: "x".into(),
575            description: None,
576            content: String::new(),
577            tags: vec![],
578            state: ContentBlockState::Active,
579        };
580        let err = client.update_content_block("id", &cb).await.unwrap_err();
581        match err {
582            BrazeApiError::Http { status, body } => {
583                assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
584                assert!(body.contains("oops"));
585            }
586            other => panic!("expected Http, got {other:?}"),
587        }
588    }
589
590    #[tokio::test]
591    async fn list_errors_when_count_exceeds_returned() {
592        let server = MockServer::start().await;
593        let entries: Vec<serde_json::Value> = (0..100)
594            .map(|i| {
595                json!({
596                    "content_block_id": format!("id-{i}"),
597                    "name": format!("block-{i}")
598                })
599            })
600            .collect();
601        Mock::given(method("GET"))
602            .and(path("/content_blocks/list"))
603            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
604                "count": 250,
605                "content_blocks": entries,
606                "message": "success"
607            })))
608            .mount(&server)
609            .await;
610        let client = make_client(&server);
611        let err = client.list_content_blocks().await.unwrap_err();
612        match err {
613            BrazeApiError::PaginationNotImplemented { endpoint, detail } => {
614                assert_eq!(endpoint, "/content_blocks/list");
615                assert!(detail.contains("100"), "detail: {detail}");
616                assert!(detail.contains("250"), "detail: {detail}");
617            }
618            other => panic!("expected PaginationNotImplemented, got {other:?}"),
619        }
620    }
621
622    #[tokio::test]
623    async fn list_errors_on_full_page_with_no_count_field() {
624        // Ambiguous case: 100 returned, no `count` to disambiguate.
625        // Fail closed rather than risk page-2 invisibility.
626        let server = MockServer::start().await;
627        let entries: Vec<serde_json::Value> = (0..100)
628            .map(|i| {
629                json!({
630                    "content_block_id": format!("id-{i}"),
631                    "name": format!("block-{i}")
632                })
633            })
634            .collect();
635        Mock::given(method("GET"))
636            .and(path("/content_blocks/list"))
637            .respond_with(
638                ResponseTemplate::new(200).set_body_json(json!({ "content_blocks": entries })),
639            )
640            .mount(&server)
641            .await;
642        let client = make_client(&server);
643        let err = client.list_content_blocks().await.unwrap_err();
644        assert!(
645            matches!(err, BrazeApiError::PaginationNotImplemented { .. }),
646            "got {err:?}"
647        );
648    }
649
650    #[tokio::test]
651    async fn list_short_page_with_no_count_is_trusted_as_complete() {
652        // Pins the `_ => None` arm of the truncation match for the
653        // non-empty-short-page-no-count case. `list_empty_array`
654        // covers the 0-entry flavour; this test nails down that a
655        // partial-but-under-LIMIT page without `count` is accepted
656        // as the full workspace. Matches the comment on
657        // `list_content_blocks` about every known paginated API
658        // returning exactly `limit` when more pages exist.
659        let server = MockServer::start().await;
660        Mock::given(method("GET"))
661            .and(path("/content_blocks/list"))
662            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
663                "content_blocks": [
664                    {"content_block_id": "id-1", "name": "a"},
665                    {"content_block_id": "id-2", "name": "b"}
666                ]
667            })))
668            .mount(&server)
669            .await;
670        let client = make_client(&server);
671        let summaries = client.list_content_blocks().await.unwrap();
672        assert_eq!(summaries.len(), 2);
673        assert_eq!(summaries[0].name, "a");
674        assert_eq!(summaries[1].name, "b");
675    }
676
677    #[tokio::test]
678    async fn list_succeeds_when_count_matches_full_page_exactly() {
679        // 100 returned + count: 100 → exact full workspace, definitely
680        // no more pages, must succeed.
681        let server = MockServer::start().await;
682        let entries: Vec<serde_json::Value> = (0..100)
683            .map(|i| {
684                json!({
685                    "content_block_id": format!("id-{i}"),
686                    "name": format!("block-{i}")
687                })
688            })
689            .collect();
690        Mock::given(method("GET"))
691            .and(path("/content_blocks/list"))
692            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
693                "count": 100,
694                "content_blocks": entries
695            })))
696            .mount(&server)
697            .await;
698        let client = make_client(&server);
699        let summaries = client.list_content_blocks().await.unwrap();
700        assert_eq!(summaries.len(), 100);
701    }
702
703    #[tokio::test]
704    async fn list_errors_on_duplicate_name_in_response() {
705        // Regression guard: if Braze ever violates its own name-uniqueness
706        // contract, the BTreeMap-based name→id index in
707        // `diff::compute_content_block_plan` would silently keep only
708        // the last id for a duplicate pair, hiding one of the two blocks
709        // from every subsequent list/update/archive op. Fail loud instead.
710        let server = MockServer::start().await;
711        Mock::given(method("GET"))
712            .and(path("/content_blocks/list"))
713            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
714                "count": 2,
715                "content_blocks": [
716                    {"content_block_id": "id-a", "name": "dup"},
717                    {"content_block_id": "id-b", "name": "dup"}
718                ]
719            })))
720            .mount(&server)
721            .await;
722        let client = make_client(&server);
723        let err = client.list_content_blocks().await.unwrap_err();
724        match err {
725            BrazeApiError::DuplicateNameInListResponse { endpoint, name } => {
726                assert_eq!(endpoint, "/content_blocks/list");
727                assert_eq!(name, "dup");
728            }
729            other => panic!("expected DuplicateNameInListResponse, got {other:?}"),
730        }
731    }
732
733    #[tokio::test]
734    async fn list_retries_on_429_then_succeeds() {
735        let server = MockServer::start().await;
736        Mock::given(method("GET"))
737            .and(path("/content_blocks/list"))
738            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
739                "content_blocks": [{"content_block_id": "id-x", "name": "x"}]
740            })))
741            .mount(&server)
742            .await;
743        Mock::given(method("GET"))
744            .and(path("/content_blocks/list"))
745            .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
746            .up_to_n_times(1)
747            .mount(&server)
748            .await;
749        let client = make_client(&server);
750        let summaries = client.list_content_blocks().await.unwrap();
751        assert_eq!(summaries.len(), 1);
752        assert_eq!(summaries[0].name, "x");
753    }
754}