Skip to main content

braze_sync/braze/
catalog.rs

1//! Catalog Schema endpoints. See IMPLEMENTATION.md §8.3.
2//!
3//! Catalog **items** endpoints (list / upsert / delete items) are not
4//! wrapped by this client: braze-sync manages Braze configuration, not
5//! runtime data. See docs/scope-boundaries.md.
6
7use crate::braze::error::BrazeApiError;
8use crate::braze::BrazeClient;
9use crate::resource::{Catalog, CatalogField, CatalogFieldType};
10use reqwest::StatusCode;
11use serde::{Deserialize, Serialize};
12
13/// Wire shape of `GET /catalogs` and `GET /catalogs/{name}` responses.
14///
15/// **ASSUMED** based on IMPLEMENTATION.md §8.3 and Braze public docs.
16/// If the actual shape differs, only this struct and the wrapping
17/// logic in this file need to change.
18///
19/// Fields use serde defaults so an unexpected-but-related shape from
20/// Braze (e.g. an extra status field) doesn't break parsing.
21#[derive(Debug, Deserialize)]
22struct CatalogsResponse {
23    #[serde(default)]
24    catalogs: Vec<Catalog>,
25    /// Pagination cursor returned by Braze when more pages exist.
26    /// Its presence is the signal we use to fail closed — see
27    /// `list_catalogs`.
28    #[serde(default)]
29    next_cursor: Option<String>,
30}
31
32impl BrazeClient {
33    /// `GET /catalogs` — list every catalog schema in the workspace.
34    ///
35    /// Fails closed on `next_cursor` rather than returning page 1: a
36    /// partial view would let `apply` re-create page-2 catalogs and
37    /// mis-report drift. Mirrors `list_content_blocks`.
38    pub async fn list_catalogs(&self) -> Result<Vec<Catalog>, BrazeApiError> {
39        let req = self.get(&["catalogs"]);
40        let resp: CatalogsResponse = self.send_json(req).await?;
41        if let Some(cursor) = resp.next_cursor.as_deref() {
42            if !cursor.is_empty() {
43                return Err(BrazeApiError::PaginationNotImplemented {
44                    endpoint: "/catalogs",
45                    detail: format!(
46                        "got {} catalog(s) plus a non-empty next_cursor; \
47                         aborting to prevent silent truncation",
48                        resp.catalogs.len()
49                    ),
50                });
51            }
52        }
53        Ok(resp.catalogs)
54    }
55
56    /// `GET /catalogs/{name}` — fetch a single catalog schema.
57    ///
58    /// 404 from Braze and an empty `catalogs` array in the response are
59    /// both mapped to [`BrazeApiError::NotFound`] so callers can branch
60    /// on "this catalog doesn't exist" without string matching on the
61    /// HTTP body.
62    pub async fn get_catalog(&self, name: &str) -> Result<Catalog, BrazeApiError> {
63        let req = self.get(&["catalogs", name]);
64        match self.send_json::<CatalogsResponse>(req).await {
65            Ok(resp) => resp
66                .catalogs
67                .into_iter()
68                .next()
69                .ok_or_else(|| BrazeApiError::NotFound {
70                    resource: format!("catalog '{name}'"),
71                }),
72            Err(BrazeApiError::Http { status, .. }) if status == StatusCode::NOT_FOUND => {
73                Err(BrazeApiError::NotFound {
74                    resource: format!("catalog '{name}'"),
75                })
76            }
77            Err(e) => Err(e),
78        }
79    }
80
81    /// `POST /catalogs` — create a new catalog with its initial schema.
82    ///
83    /// Duplicate names surface as `400` with body
84    /// `error_id: "catalog-name-already-exists"` per Braze docs and are
85    /// propagated to the caller; a subsequent `apply` will see the
86    /// existing catalog and no-op on the create.
87    pub async fn create_catalog(&self, catalog: &Catalog) -> Result<(), BrazeApiError> {
88        let body = CreateCatalogRequest {
89            catalogs: vec![CreateCatalogEntry {
90                name: &catalog.name,
91                description: catalog.description.as_deref(),
92                fields: catalog
93                    .fields
94                    .iter()
95                    .map(|f| WireField {
96                        name: &f.name,
97                        field_type: f.field_type,
98                    })
99                    .collect(),
100            }],
101        };
102        let req = self.post(&["catalogs"]).json(&body);
103        self.send_ok(req).await
104    }
105
106    /// `POST /catalogs/{name}/fields` — add one field to a catalog schema.
107    ///
108    /// **ASSUMED** wire format `{"fields": [{"name": "...", "type": "..."}]}`
109    /// per IMPLEMENTATION.md §8.3 + Braze public docs. v0.1.0 sends one
110    /// POST per added field.
111    pub async fn add_catalog_field(
112        &self,
113        catalog_name: &str,
114        field: &CatalogField,
115    ) -> Result<(), BrazeApiError> {
116        let body = AddFieldsRequest {
117            fields: vec![WireField {
118                name: &field.name,
119                field_type: field.field_type,
120            }],
121        };
122        let req = self.post(&["catalogs", catalog_name, "fields"]).json(&body);
123        self.send_ok(req).await
124    }
125
126    /// `DELETE /catalogs/{name}/fields/{field}` — remove a field. **Destructive**.
127    ///
128    /// 404 from Braze stays as `Http { status: 404, .. }` rather than
129    /// being mapped to `NotFound`. The use case is different from
130    /// get_catalog: a 404 here means "the field you wanted to delete is
131    /// already gone", which is a state-drift signal the user should see
132    /// rather than silently no-op. A future `--ignore-missing` flag in
133    /// `apply` can opt into idempotent behavior.
134    pub async fn delete_catalog_field(
135        &self,
136        catalog_name: &str,
137        field_name: &str,
138    ) -> Result<(), BrazeApiError> {
139        let req = self.delete(&["catalogs", catalog_name, "fields", field_name]);
140        self.send_ok(req).await
141    }
142}
143
144#[derive(Serialize)]
145struct AddFieldsRequest<'a> {
146    fields: Vec<WireField<'a>>,
147}
148
149#[derive(Serialize)]
150struct CreateCatalogRequest<'a> {
151    catalogs: Vec<CreateCatalogEntry<'a>>,
152}
153
154#[derive(Serialize)]
155struct CreateCatalogEntry<'a> {
156    name: &'a str,
157    #[serde(skip_serializing_if = "Option::is_none")]
158    description: Option<&'a str>,
159    fields: Vec<WireField<'a>>,
160}
161
162#[derive(Serialize)]
163struct WireField<'a> {
164    name: &'a str,
165    /// Reuses the domain type's snake_case `Serialize` impl so the
166    /// wire string stays in sync with `CatalogFieldType` automatically.
167    #[serde(rename = "type")]
168    field_type: CatalogFieldType,
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use crate::braze::test_client as make_client;
175    use serde_json::json;
176    use wiremock::matchers::{body_json, header, method, path};
177    use wiremock::{Mock, MockServer, ResponseTemplate};
178
179    #[tokio::test]
180    async fn list_catalogs_happy_path() {
181        let server = MockServer::start().await;
182        Mock::given(method("GET"))
183            .and(path("/catalogs"))
184            .and(header("authorization", "Bearer test-key"))
185            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
186                "catalogs": [
187                    {
188                        "name": "cardiology",
189                        "description": "Cardiology catalog",
190                        "fields": [
191                            {"name": "id", "type": "string"},
192                            {"name": "score", "type": "number"}
193                        ]
194                    },
195                    {
196                        "name": "dermatology",
197                        "fields": [
198                            {"name": "id", "type": "string"}
199                        ]
200                    }
201                ],
202                "message": "success"
203            })))
204            .mount(&server)
205            .await;
206
207        let client = make_client(&server);
208        let cats = client.list_catalogs().await.unwrap();
209        assert_eq!(cats.len(), 2);
210        assert_eq!(cats[0].name, "cardiology");
211        assert_eq!(cats[0].description.as_deref(), Some("Cardiology catalog"));
212        assert_eq!(cats[0].fields.len(), 2);
213        assert_eq!(cats[0].fields[1].field_type, CatalogFieldType::Number);
214        assert_eq!(cats[1].name, "dermatology");
215        assert_eq!(cats[1].description, None);
216    }
217
218    #[tokio::test]
219    async fn list_catalogs_empty() {
220        let server = MockServer::start().await;
221        Mock::given(method("GET"))
222            .and(path("/catalogs"))
223            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"catalogs": []})))
224            .mount(&server)
225            .await;
226        let client = make_client(&server);
227        let cats = client.list_catalogs().await.unwrap();
228        assert!(cats.is_empty());
229    }
230
231    #[tokio::test]
232    async fn list_catalogs_sets_user_agent() {
233        let server = MockServer::start().await;
234        Mock::given(method("GET"))
235            .and(path("/catalogs"))
236            .and(header(
237                "user-agent",
238                concat!("braze-sync/", env!("CARGO_PKG_VERSION")),
239            ))
240            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"catalogs": []})))
241            .mount(&server)
242            .await;
243        let client = make_client(&server);
244        client.list_catalogs().await.unwrap();
245    }
246
247    #[tokio::test]
248    async fn list_catalogs_ignores_unknown_fields_in_response() {
249        // Forward compat: a future Braze response with extra fields
250        // (top-level and inside catalog entries) should still parse
251        // because no struct in the chain uses deny_unknown_fields.
252        let server = MockServer::start().await;
253        Mock::given(method("GET"))
254            .and(path("/catalogs"))
255            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
256                "catalogs": [
257                    {
258                        "name": "future",
259                        "description": "tomorrow",
260                        "future_metadata": {"foo": "bar"},
261                        "num_items": 1234,
262                        "fields": [
263                            {"name": "id", "type": "string", "extra": "ignored"}
264                        ]
265                    }
266                ],
267                "future_top_level": {"whatever": true},
268                "message": "success"
269            })))
270            .mount(&server)
271            .await;
272        let client = make_client(&server);
273        let cats = client.list_catalogs().await.unwrap();
274        assert_eq!(cats.len(), 1);
275        assert_eq!(cats[0].name, "future");
276    }
277
278    #[tokio::test]
279    async fn list_catalogs_errors_when_next_cursor_present() {
280        // Regression guard: v0.2.0 silently returned page 1 here.
281        let server = MockServer::start().await;
282        Mock::given(method("GET"))
283            .and(path("/catalogs"))
284            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
285                "catalogs": [
286                    {"name": "cardiology", "fields": [{"name": "id", "type": "string"}]}
287                ],
288                "next_cursor": "abc123"
289            })))
290            .mount(&server)
291            .await;
292        let client = make_client(&server);
293        let err = client.list_catalogs().await.unwrap_err();
294        match err {
295            BrazeApiError::PaginationNotImplemented { endpoint, detail } => {
296                assert_eq!(endpoint, "/catalogs");
297                assert!(detail.contains("next_cursor"), "detail: {detail}");
298                assert!(detail.contains("1 catalog"), "detail: {detail}");
299            }
300            other => panic!("expected PaginationNotImplemented, got {other:?}"),
301        }
302    }
303
304    #[tokio::test]
305    async fn list_catalogs_empty_string_cursor_is_treated_as_no_more_pages() {
306        // Some paginated APIs return `next_cursor: ""` on the last
307        // page instead of omitting the field. Treat that as "no more
308        // pages" rather than tripping the fail-closed guard — the
309        // alternative would turn every workspace under one page into
310        // an error.
311        let server = MockServer::start().await;
312        Mock::given(method("GET"))
313            .and(path("/catalogs"))
314            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
315                "catalogs": [{"name": "only", "fields": []}],
316                "next_cursor": ""
317            })))
318            .mount(&server)
319            .await;
320        let client = make_client(&server);
321        let cats = client.list_catalogs().await.unwrap();
322        assert_eq!(cats.len(), 1);
323        assert_eq!(cats[0].name, "only");
324    }
325
326    #[tokio::test]
327    async fn unauthorized_returns_typed_error() {
328        let server = MockServer::start().await;
329        Mock::given(method("GET"))
330            .and(path("/catalogs"))
331            .respond_with(ResponseTemplate::new(401).set_body_string("invalid api key"))
332            .mount(&server)
333            .await;
334        let client = make_client(&server);
335        let err = client.list_catalogs().await.unwrap_err();
336        assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
337    }
338
339    #[tokio::test]
340    async fn server_error_carries_status_and_body() {
341        let server = MockServer::start().await;
342        Mock::given(method("GET"))
343            .and(path("/catalogs"))
344            .respond_with(ResponseTemplate::new(500).set_body_string("internal explosion"))
345            .mount(&server)
346            .await;
347        let client = make_client(&server);
348        let err = client.list_catalogs().await.unwrap_err();
349        match err {
350            BrazeApiError::Http { status, body } => {
351                assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
352                assert!(body.contains("internal explosion"));
353            }
354            other => panic!("expected Http, got {other:?}"),
355        }
356    }
357
358    #[tokio::test]
359    async fn retries_on_429_and_succeeds() {
360        let server = MockServer::start().await;
361        // wiremock matches the *most recently mounted* mock first; the
362        // limited 429 mock is mounted second so it preempts until used
363        // up, after which the success mock takes over.
364        Mock::given(method("GET"))
365            .and(path("/catalogs"))
366            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
367                "catalogs": [{"name": "after_retry", "fields": []}]
368            })))
369            .mount(&server)
370            .await;
371        Mock::given(method("GET"))
372            .and(path("/catalogs"))
373            .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
374            .up_to_n_times(1)
375            .mount(&server)
376            .await;
377
378        let client = make_client(&server);
379        let cats = client.list_catalogs().await.unwrap();
380        assert_eq!(cats.len(), 1);
381        assert_eq!(cats[0].name, "after_retry");
382    }
383
384    #[tokio::test]
385    async fn retries_exhausted_returns_rate_limit_exhausted() {
386        let server = MockServer::start().await;
387        Mock::given(method("GET"))
388            .and(path("/catalogs"))
389            .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
390            .mount(&server)
391            .await;
392        let client = make_client(&server);
393        let err = client.list_catalogs().await.unwrap_err();
394        assert!(
395            matches!(err, BrazeApiError::RateLimitExhausted),
396            "got {err:?}"
397        );
398    }
399
400    #[tokio::test]
401    async fn get_catalog_happy_path() {
402        let server = MockServer::start().await;
403        Mock::given(method("GET"))
404            .and(path("/catalogs/cardiology"))
405            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
406                "catalogs": [
407                    {"name": "cardiology", "fields": [{"name": "id", "type": "string"}]}
408                ]
409            })))
410            .mount(&server)
411            .await;
412        let client = make_client(&server);
413        let cat = client.get_catalog("cardiology").await.unwrap();
414        assert_eq!(cat.name, "cardiology");
415        assert_eq!(cat.fields.len(), 1);
416    }
417
418    #[tokio::test]
419    async fn get_catalog_404_is_mapped_to_not_found() {
420        let server = MockServer::start().await;
421        Mock::given(method("GET"))
422            .and(path("/catalogs/missing"))
423            .respond_with(ResponseTemplate::new(404).set_body_string("not found"))
424            .mount(&server)
425            .await;
426        let client = make_client(&server);
427        let err = client.get_catalog("missing").await.unwrap_err();
428        match err {
429            BrazeApiError::NotFound { resource } => assert!(resource.contains("missing")),
430            other => panic!("expected NotFound, got {other:?}"),
431        }
432    }
433
434    #[tokio::test]
435    async fn get_catalog_empty_response_array_is_not_found() {
436        let server = MockServer::start().await;
437        Mock::given(method("GET"))
438            .and(path("/catalogs/ghost"))
439            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"catalogs": []})))
440            .mount(&server)
441            .await;
442        let client = make_client(&server);
443        let err = client.get_catalog("ghost").await.unwrap_err();
444        assert!(matches!(err, BrazeApiError::NotFound { .. }), "got {err:?}");
445    }
446
447    #[tokio::test]
448    async fn debug_does_not_leak_api_key() {
449        let server = MockServer::start().await;
450        let client = make_client(&server);
451        let dbg = format!("{client:?}");
452        assert!(!dbg.contains("test-key"), "leaked api key in: {dbg}");
453        assert!(dbg.contains("<redacted>"));
454    }
455
456    #[tokio::test]
457    async fn add_catalog_field_happy_path_sends_correct_body() {
458        let server = MockServer::start().await;
459        Mock::given(method("POST"))
460            .and(path("/catalogs/cardiology/fields"))
461            .and(header("authorization", "Bearer test-key"))
462            .and(body_json(json!({
463                "fields": [{"name": "severity_level", "type": "number"}]
464            })))
465            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"message": "success"})))
466            .mount(&server)
467            .await;
468
469        let client = make_client(&server);
470        let field = CatalogField {
471            name: "severity_level".into(),
472            field_type: CatalogFieldType::Number,
473        };
474        client
475            .add_catalog_field("cardiology", &field)
476            .await
477            .unwrap();
478    }
479
480    #[tokio::test]
481    async fn add_catalog_field_unauthorized_propagates() {
482        let server = MockServer::start().await;
483        Mock::given(method("POST"))
484            .and(path("/catalogs/cardiology/fields"))
485            .respond_with(ResponseTemplate::new(401).set_body_string("invalid key"))
486            .mount(&server)
487            .await;
488
489        let client = make_client(&server);
490        let field = CatalogField {
491            name: "x".into(),
492            field_type: CatalogFieldType::String,
493        };
494        let err = client
495            .add_catalog_field("cardiology", &field)
496            .await
497            .unwrap_err();
498        assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
499    }
500
501    #[tokio::test]
502    async fn add_catalog_field_retries_on_429_then_succeeds() {
503        let server = MockServer::start().await;
504        // Success mounted first; the limited 429 mock is mounted second
505        // and wiremock matches the most-recently-mounted one until it
506        // exhausts its `up_to_n_times` budget.
507        Mock::given(method("POST"))
508            .and(path("/catalogs/cardiology/fields"))
509            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"message": "ok"})))
510            .mount(&server)
511            .await;
512        Mock::given(method("POST"))
513            .and(path("/catalogs/cardiology/fields"))
514            .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
515            .up_to_n_times(1)
516            .mount(&server)
517            .await;
518
519        let client = make_client(&server);
520        let field = CatalogField {
521            name: "x".into(),
522            field_type: CatalogFieldType::String,
523        };
524        client
525            .add_catalog_field("cardiology", &field)
526            .await
527            .unwrap();
528    }
529
530    #[tokio::test]
531    async fn create_catalog_happy_path_sends_correct_body() {
532        let server = MockServer::start().await;
533        Mock::given(method("POST"))
534            .and(path("/catalogs"))
535            .and(header("authorization", "Bearer test-key"))
536            .and(body_json(json!({
537                "catalogs": [{
538                    "name": "cardiology",
539                    "description": "Cardiology catalog",
540                    "fields": [
541                        {"name": "id", "type": "string"},
542                        {"name": "severity_level", "type": "number"}
543                    ]
544                }]
545            })))
546            .respond_with(ResponseTemplate::new(201).set_body_json(json!({"message": "success"})))
547            .mount(&server)
548            .await;
549
550        let client = make_client(&server);
551        let cat = Catalog {
552            name: "cardiology".into(),
553            description: Some("Cardiology catalog".into()),
554            fields: vec![
555                CatalogField {
556                    name: "id".into(),
557                    field_type: CatalogFieldType::String,
558                },
559                CatalogField {
560                    name: "severity_level".into(),
561                    field_type: CatalogFieldType::Number,
562                },
563            ],
564        };
565        client.create_catalog(&cat).await.unwrap();
566    }
567
568    #[tokio::test]
569    async fn create_catalog_omits_description_when_none() {
570        let server = MockServer::start().await;
571        Mock::given(method("POST"))
572            .and(path("/catalogs"))
573            .and(body_json(json!({
574                "catalogs": [{
575                    "name": "minimal",
576                    "fields": [{"name": "id", "type": "string"}]
577                }]
578            })))
579            .respond_with(ResponseTemplate::new(201).set_body_json(json!({"message": "success"})))
580            .mount(&server)
581            .await;
582
583        let client = make_client(&server);
584        let cat = Catalog {
585            name: "minimal".into(),
586            description: None,
587            fields: vec![CatalogField {
588                name: "id".into(),
589                field_type: CatalogFieldType::String,
590            }],
591        };
592        client.create_catalog(&cat).await.unwrap();
593    }
594
595    #[tokio::test]
596    async fn create_catalog_duplicate_name_propagates_400() {
597        let server = MockServer::start().await;
598        Mock::given(method("POST"))
599            .and(path("/catalogs"))
600            .respond_with(ResponseTemplate::new(400).set_body_json(json!({
601                "errors": [{
602                    "id": "catalog-name-already-exists",
603                    "message": "A catalog with that name already exists"
604                }]
605            })))
606            .mount(&server)
607            .await;
608
609        let client = make_client(&server);
610        let cat = Catalog {
611            name: "existing".into(),
612            description: None,
613            fields: vec![],
614        };
615        let err = client.create_catalog(&cat).await.unwrap_err();
616        assert!(
617            matches!(
618                &err,
619                BrazeApiError::Http { status, body }
620                    if *status == StatusCode::BAD_REQUEST
621                        && body.contains("catalog-name-already-exists")
622            ),
623            "got {err:?}"
624        );
625    }
626
627    #[tokio::test]
628    async fn create_catalog_unauthorized_propagates() {
629        let server = MockServer::start().await;
630        Mock::given(method("POST"))
631            .and(path("/catalogs"))
632            .respond_with(ResponseTemplate::new(401).set_body_string("invalid key"))
633            .mount(&server)
634            .await;
635
636        let client = make_client(&server);
637        let cat = Catalog {
638            name: "x".into(),
639            description: None,
640            fields: vec![],
641        };
642        let err = client.create_catalog(&cat).await.unwrap_err();
643        assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
644    }
645
646    #[tokio::test]
647    async fn create_catalog_retries_on_429_then_succeeds() {
648        let server = MockServer::start().await;
649        Mock::given(method("POST"))
650            .and(path("/catalogs"))
651            .respond_with(ResponseTemplate::new(201).set_body_json(json!({"message": "ok"})))
652            .mount(&server)
653            .await;
654        Mock::given(method("POST"))
655            .and(path("/catalogs"))
656            .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
657            .up_to_n_times(1)
658            .mount(&server)
659            .await;
660
661        let client = make_client(&server);
662        let cat = Catalog {
663            name: "x".into(),
664            description: None,
665            fields: vec![CatalogField {
666                name: "id".into(),
667                field_type: CatalogFieldType::String,
668            }],
669        };
670        client.create_catalog(&cat).await.unwrap();
671    }
672
673    #[tokio::test]
674    async fn delete_catalog_field_happy_path_uses_segment_encoded_path() {
675        let server = MockServer::start().await;
676        Mock::given(method("DELETE"))
677            .and(path("/catalogs/cardiology/fields/legacy_code"))
678            .and(header("authorization", "Bearer test-key"))
679            .respond_with(ResponseTemplate::new(204))
680            .mount(&server)
681            .await;
682
683        let client = make_client(&server);
684        client
685            .delete_catalog_field("cardiology", "legacy_code")
686            .await
687            .unwrap();
688    }
689
690    #[tokio::test]
691    async fn delete_catalog_field_server_error_returns_http() {
692        let server = MockServer::start().await;
693        Mock::given(method("DELETE"))
694            .and(path("/catalogs/cardiology/fields/x"))
695            .respond_with(ResponseTemplate::new(500).set_body_string("oops"))
696            .mount(&server)
697            .await;
698
699        let client = make_client(&server);
700        let err = client
701            .delete_catalog_field("cardiology", "x")
702            .await
703            .unwrap_err();
704        match err {
705            BrazeApiError::Http { status, body } => {
706                assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
707                assert!(body.contains("oops"));
708            }
709            other => panic!("expected Http, got {other:?}"),
710        }
711    }
712}