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