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    /// Normalizes field order before sending: Braze rejects creates whose
84    /// first field is not `id` (HTTP 400, `id-not-first-column`), and
85    /// on-disk schemas exported from existing workspaces are typically
86    /// alphabetized — so a literal `id` field can land mid-array.
87    /// `Catalog::normalized()` hoists `id` to position 0.
88    ///
89    /// Duplicate names surface as `400` with body
90    /// `error_id: "catalog-name-already-exists"` per Braze docs and are
91    /// propagated to the caller; a subsequent `apply` will see the
92    /// existing catalog and no-op on the create.
93    pub async fn create_catalog(&self, catalog: &Catalog) -> Result<(), BrazeApiError> {
94        let normalized = catalog.normalized();
95        let body = CreateCatalogRequest {
96            catalogs: vec![CreateCatalogEntry {
97                name: &normalized.name,
98                description: normalized.description.as_deref(),
99                fields: normalized
100                    .fields
101                    .iter()
102                    .map(|f| WireField {
103                        name: &f.name,
104                        field_type: f.field_type,
105                    })
106                    .collect(),
107            }],
108        };
109        let req = self.post(&["catalogs"]).json(&body);
110        self.send_ok(req).await
111    }
112
113    /// `POST /catalogs/{name}/fields` — add one field to a catalog schema.
114    ///
115    /// **ASSUMED** wire format `{"fields": [{"name": "...", "type": "..."}]}`
116    /// per IMPLEMENTATION.md §8.3 + Braze public docs. v0.1.0 sends one
117    /// POST per added field.
118    pub async fn add_catalog_field(
119        &self,
120        catalog_name: &str,
121        field: &CatalogField,
122    ) -> Result<(), BrazeApiError> {
123        let body = AddFieldsRequest {
124            fields: vec![WireField {
125                name: &field.name,
126                field_type: field.field_type,
127            }],
128        };
129        let req = self.post(&["catalogs", catalog_name, "fields"]).json(&body);
130        self.send_ok(req).await
131    }
132
133    /// `DELETE /catalogs/{name}/fields/{field}` — remove a field. **Destructive**.
134    ///
135    /// 404 from Braze stays as `Http { status: 404, .. }` rather than
136    /// being mapped to `NotFound`. The use case is different from
137    /// get_catalog: a 404 here means "the field you wanted to delete is
138    /// already gone", which is a state-drift signal the user should see
139    /// rather than silently no-op. A future `--ignore-missing` flag in
140    /// `apply` can opt into idempotent behavior.
141    pub async fn delete_catalog_field(
142        &self,
143        catalog_name: &str,
144        field_name: &str,
145    ) -> Result<(), BrazeApiError> {
146        let req = self.delete(&["catalogs", catalog_name, "fields", field_name]);
147        self.send_ok(req).await
148    }
149}
150
151#[derive(Serialize)]
152struct AddFieldsRequest<'a> {
153    fields: Vec<WireField<'a>>,
154}
155
156#[derive(Serialize)]
157struct CreateCatalogRequest<'a> {
158    catalogs: Vec<CreateCatalogEntry<'a>>,
159}
160
161#[derive(Serialize)]
162struct CreateCatalogEntry<'a> {
163    name: &'a str,
164    #[serde(skip_serializing_if = "Option::is_none")]
165    description: Option<&'a str>,
166    fields: Vec<WireField<'a>>,
167}
168
169#[derive(Serialize)]
170struct WireField<'a> {
171    name: &'a str,
172    /// Reuses the domain type's snake_case `Serialize` impl so the
173    /// wire string stays in sync with `CatalogFieldType` automatically.
174    #[serde(rename = "type")]
175    field_type: CatalogFieldType,
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use crate::braze::test_client as make_client;
182    use serde_json::json;
183    use wiremock::matchers::{body_json, header, method, path};
184    use wiremock::{Mock, MockServer, ResponseTemplate};
185
186    #[tokio::test]
187    async fn list_catalogs_happy_path() {
188        let server = MockServer::start().await;
189        Mock::given(method("GET"))
190            .and(path("/catalogs"))
191            .and(header("authorization", "Bearer test-key"))
192            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
193                "catalogs": [
194                    {
195                        "name": "cardiology",
196                        "description": "Cardiology catalog",
197                        "fields": [
198                            {"name": "id", "type": "string"},
199                            {"name": "score", "type": "number"}
200                        ]
201                    },
202                    {
203                        "name": "dermatology",
204                        "fields": [
205                            {"name": "id", "type": "string"}
206                        ]
207                    }
208                ],
209                "message": "success"
210            })))
211            .mount(&server)
212            .await;
213
214        let client = make_client(&server);
215        let cats = client.list_catalogs().await.unwrap();
216        assert_eq!(cats.len(), 2);
217        assert_eq!(cats[0].name, "cardiology");
218        assert_eq!(cats[0].description.as_deref(), Some("Cardiology catalog"));
219        assert_eq!(cats[0].fields.len(), 2);
220        assert_eq!(cats[0].fields[1].field_type, CatalogFieldType::Number);
221        assert_eq!(cats[1].name, "dermatology");
222        assert_eq!(cats[1].description, None);
223    }
224
225    #[tokio::test]
226    async fn list_catalogs_empty() {
227        let server = MockServer::start().await;
228        Mock::given(method("GET"))
229            .and(path("/catalogs"))
230            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"catalogs": []})))
231            .mount(&server)
232            .await;
233        let client = make_client(&server);
234        let cats = client.list_catalogs().await.unwrap();
235        assert!(cats.is_empty());
236    }
237
238    #[tokio::test]
239    async fn list_catalogs_sets_user_agent() {
240        let server = MockServer::start().await;
241        Mock::given(method("GET"))
242            .and(path("/catalogs"))
243            .and(header(
244                "user-agent",
245                concat!("braze-sync/", env!("CARGO_PKG_VERSION")),
246            ))
247            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"catalogs": []})))
248            .mount(&server)
249            .await;
250        let client = make_client(&server);
251        client.list_catalogs().await.unwrap();
252    }
253
254    #[tokio::test]
255    async fn list_catalogs_ignores_unknown_fields_in_response() {
256        // Forward compat: a future Braze response with extra fields
257        // (top-level and inside catalog entries) should still parse
258        // because no struct in the chain uses deny_unknown_fields.
259        let server = MockServer::start().await;
260        Mock::given(method("GET"))
261            .and(path("/catalogs"))
262            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
263                "catalogs": [
264                    {
265                        "name": "future",
266                        "description": "tomorrow",
267                        "future_metadata": {"foo": "bar"},
268                        "num_items": 1234,
269                        "fields": [
270                            {"name": "id", "type": "string", "extra": "ignored"}
271                        ]
272                    }
273                ],
274                "future_top_level": {"whatever": true},
275                "message": "success"
276            })))
277            .mount(&server)
278            .await;
279        let client = make_client(&server);
280        let cats = client.list_catalogs().await.unwrap();
281        assert_eq!(cats.len(), 1);
282        assert_eq!(cats[0].name, "future");
283    }
284
285    #[tokio::test]
286    async fn list_catalogs_errors_when_next_cursor_present() {
287        // Regression guard: v0.2.0 silently returned page 1 here.
288        let server = MockServer::start().await;
289        Mock::given(method("GET"))
290            .and(path("/catalogs"))
291            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
292                "catalogs": [
293                    {"name": "cardiology", "fields": [{"name": "id", "type": "string"}]}
294                ],
295                "next_cursor": "abc123"
296            })))
297            .mount(&server)
298            .await;
299        let client = make_client(&server);
300        let err = client.list_catalogs().await.unwrap_err();
301        match err {
302            BrazeApiError::PaginationNotImplemented { endpoint, detail } => {
303                assert_eq!(endpoint, "/catalogs");
304                assert!(detail.contains("next_cursor"), "detail: {detail}");
305                assert!(detail.contains("1 catalog"), "detail: {detail}");
306            }
307            other => panic!("expected PaginationNotImplemented, got {other:?}"),
308        }
309    }
310
311    #[tokio::test]
312    async fn list_catalogs_empty_string_cursor_is_treated_as_no_more_pages() {
313        // Some paginated APIs return `next_cursor: ""` on the last
314        // page instead of omitting the field. Treat that as "no more
315        // pages" rather than tripping the fail-closed guard — the
316        // alternative would turn every workspace under one page into
317        // an error.
318        let server = MockServer::start().await;
319        Mock::given(method("GET"))
320            .and(path("/catalogs"))
321            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
322                "catalogs": [{"name": "only", "fields": []}],
323                "next_cursor": ""
324            })))
325            .mount(&server)
326            .await;
327        let client = make_client(&server);
328        let cats = client.list_catalogs().await.unwrap();
329        assert_eq!(cats.len(), 1);
330        assert_eq!(cats[0].name, "only");
331    }
332
333    #[tokio::test]
334    async fn unauthorized_returns_typed_error() {
335        let server = MockServer::start().await;
336        Mock::given(method("GET"))
337            .and(path("/catalogs"))
338            .respond_with(ResponseTemplate::new(401).set_body_string("invalid api key"))
339            .mount(&server)
340            .await;
341        let client = make_client(&server);
342        let err = client.list_catalogs().await.unwrap_err();
343        assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
344    }
345
346    #[tokio::test]
347    async fn server_error_carries_status_and_body() {
348        let server = MockServer::start().await;
349        Mock::given(method("GET"))
350            .and(path("/catalogs"))
351            .respond_with(ResponseTemplate::new(500).set_body_string("internal explosion"))
352            .mount(&server)
353            .await;
354        let client = make_client(&server);
355        let err = client.list_catalogs().await.unwrap_err();
356        match err {
357            BrazeApiError::Http { status, body } => {
358                assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
359                assert!(body.contains("internal explosion"));
360            }
361            other => panic!("expected Http, got {other:?}"),
362        }
363    }
364
365    #[tokio::test]
366    async fn retries_on_429_and_succeeds() {
367        let server = MockServer::start().await;
368        // wiremock matches the *most recently mounted* mock first; the
369        // limited 429 mock is mounted second so it preempts until used
370        // up, after which the success mock takes over.
371        Mock::given(method("GET"))
372            .and(path("/catalogs"))
373            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
374                "catalogs": [{"name": "after_retry", "fields": []}]
375            })))
376            .mount(&server)
377            .await;
378        Mock::given(method("GET"))
379            .and(path("/catalogs"))
380            .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
381            .up_to_n_times(1)
382            .mount(&server)
383            .await;
384
385        let client = make_client(&server);
386        let cats = client.list_catalogs().await.unwrap();
387        assert_eq!(cats.len(), 1);
388        assert_eq!(cats[0].name, "after_retry");
389    }
390
391    #[tokio::test]
392    async fn retries_exhausted_returns_rate_limit_exhausted() {
393        let server = MockServer::start().await;
394        Mock::given(method("GET"))
395            .and(path("/catalogs"))
396            .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
397            .mount(&server)
398            .await;
399        let client = make_client(&server);
400        let err = client.list_catalogs().await.unwrap_err();
401        assert!(
402            matches!(err, BrazeApiError::RateLimitExhausted),
403            "got {err:?}"
404        );
405    }
406
407    #[tokio::test]
408    async fn get_catalog_happy_path() {
409        let server = MockServer::start().await;
410        Mock::given(method("GET"))
411            .and(path("/catalogs/cardiology"))
412            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
413                "catalogs": [
414                    {"name": "cardiology", "fields": [{"name": "id", "type": "string"}]}
415                ]
416            })))
417            .mount(&server)
418            .await;
419        let client = make_client(&server);
420        let cat = client.get_catalog("cardiology").await.unwrap();
421        assert_eq!(cat.name, "cardiology");
422        assert_eq!(cat.fields.len(), 1);
423    }
424
425    #[tokio::test]
426    async fn get_catalog_404_is_mapped_to_not_found() {
427        let server = MockServer::start().await;
428        Mock::given(method("GET"))
429            .and(path("/catalogs/missing"))
430            .respond_with(ResponseTemplate::new(404).set_body_string("not found"))
431            .mount(&server)
432            .await;
433        let client = make_client(&server);
434        let err = client.get_catalog("missing").await.unwrap_err();
435        match err {
436            BrazeApiError::NotFound { resource } => assert!(resource.contains("missing")),
437            other => panic!("expected NotFound, got {other:?}"),
438        }
439    }
440
441    #[tokio::test]
442    async fn get_catalog_empty_response_array_is_not_found() {
443        let server = MockServer::start().await;
444        Mock::given(method("GET"))
445            .and(path("/catalogs/ghost"))
446            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"catalogs": []})))
447            .mount(&server)
448            .await;
449        let client = make_client(&server);
450        let err = client.get_catalog("ghost").await.unwrap_err();
451        assert!(matches!(err, BrazeApiError::NotFound { .. }), "got {err:?}");
452    }
453
454    #[tokio::test]
455    async fn debug_does_not_leak_api_key() {
456        let server = MockServer::start().await;
457        let client = make_client(&server);
458        let dbg = format!("{client:?}");
459        assert!(!dbg.contains("test-key"), "leaked api key in: {dbg}");
460        assert!(dbg.contains("<redacted>"));
461    }
462
463    #[tokio::test]
464    async fn add_catalog_field_happy_path_sends_correct_body() {
465        let server = MockServer::start().await;
466        Mock::given(method("POST"))
467            .and(path("/catalogs/cardiology/fields"))
468            .and(header("authorization", "Bearer test-key"))
469            .and(body_json(json!({
470                "fields": [{"name": "severity_level", "type": "number"}]
471            })))
472            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"message": "success"})))
473            .mount(&server)
474            .await;
475
476        let client = make_client(&server);
477        let field = CatalogField {
478            name: "severity_level".into(),
479            field_type: CatalogFieldType::Number,
480        };
481        client
482            .add_catalog_field("cardiology", &field)
483            .await
484            .unwrap();
485    }
486
487    #[tokio::test]
488    async fn add_catalog_field_unauthorized_propagates() {
489        let server = MockServer::start().await;
490        Mock::given(method("POST"))
491            .and(path("/catalogs/cardiology/fields"))
492            .respond_with(ResponseTemplate::new(401).set_body_string("invalid key"))
493            .mount(&server)
494            .await;
495
496        let client = make_client(&server);
497        let field = CatalogField {
498            name: "x".into(),
499            field_type: CatalogFieldType::String,
500        };
501        let err = client
502            .add_catalog_field("cardiology", &field)
503            .await
504            .unwrap_err();
505        assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
506    }
507
508    #[tokio::test]
509    async fn add_catalog_field_retries_on_429_then_succeeds() {
510        let server = MockServer::start().await;
511        // Success mounted first; the limited 429 mock is mounted second
512        // and wiremock matches the most-recently-mounted one until it
513        // exhausts its `up_to_n_times` budget.
514        Mock::given(method("POST"))
515            .and(path("/catalogs/cardiology/fields"))
516            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"message": "ok"})))
517            .mount(&server)
518            .await;
519        Mock::given(method("POST"))
520            .and(path("/catalogs/cardiology/fields"))
521            .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
522            .up_to_n_times(1)
523            .mount(&server)
524            .await;
525
526        let client = make_client(&server);
527        let field = CatalogField {
528            name: "x".into(),
529            field_type: CatalogFieldType::String,
530        };
531        client
532            .add_catalog_field("cardiology", &field)
533            .await
534            .unwrap();
535    }
536
537    #[tokio::test]
538    async fn create_catalog_happy_path_sends_correct_body() {
539        let server = MockServer::start().await;
540        Mock::given(method("POST"))
541            .and(path("/catalogs"))
542            .and(header("authorization", "Bearer test-key"))
543            .and(body_json(json!({
544                "catalogs": [{
545                    "name": "cardiology",
546                    "description": "Cardiology catalog",
547                    "fields": [
548                        {"name": "id", "type": "string"},
549                        {"name": "severity_level", "type": "number"}
550                    ]
551                }]
552            })))
553            .respond_with(ResponseTemplate::new(201).set_body_json(json!({"message": "success"})))
554            .mount(&server)
555            .await;
556
557        let client = make_client(&server);
558        let cat = Catalog {
559            name: "cardiology".into(),
560            description: Some("Cardiology catalog".into()),
561            fields: vec![
562                CatalogField {
563                    name: "id".into(),
564                    field_type: CatalogFieldType::String,
565                },
566                CatalogField {
567                    name: "severity_level".into(),
568                    field_type: CatalogFieldType::Number,
569                },
570            ],
571        };
572        client.create_catalog(&cat).await.unwrap();
573    }
574
575    #[tokio::test]
576    async fn create_catalog_hoists_id_field_to_first_position() {
577        // Braze returns HTTP 400 `id-not-first-column` when fields[0]
578        // is not `id`. Repos exported by braze-sync sort fields
579        // alphabetically on disk, so a literal `id` lands mid-array;
580        // create_catalog must normalize before sending.
581        let server = MockServer::start().await;
582        Mock::given(method("POST"))
583            .and(path("/catalogs"))
584            .and(body_json(json!({
585                "catalogs": [{
586                    "name": "alpha",
587                    "fields": [
588                        {"name": "id", "type": "string"},
589                        {"name": "URL", "type": "string"},
590                        {"name": "author", "type": "string"},
591                        {"name": "title", "type": "string"}
592                    ]
593                }]
594            })))
595            .respond_with(ResponseTemplate::new(201).set_body_json(json!({"message": "success"})))
596            .mount(&server)
597            .await;
598
599        let client = make_client(&server);
600        let cat = Catalog {
601            name: "alpha".into(),
602            description: None,
603            fields: vec![
604                CatalogField {
605                    name: "URL".into(),
606                    field_type: CatalogFieldType::String,
607                },
608                CatalogField {
609                    name: "author".into(),
610                    field_type: CatalogFieldType::String,
611                },
612                CatalogField {
613                    name: "id".into(),
614                    field_type: CatalogFieldType::String,
615                },
616                CatalogField {
617                    name: "title".into(),
618                    field_type: CatalogFieldType::String,
619                },
620            ],
621        };
622        client.create_catalog(&cat).await.unwrap();
623    }
624
625    #[tokio::test]
626    async fn create_catalog_omits_description_when_none() {
627        let server = MockServer::start().await;
628        Mock::given(method("POST"))
629            .and(path("/catalogs"))
630            .and(body_json(json!({
631                "catalogs": [{
632                    "name": "minimal",
633                    "fields": [{"name": "id", "type": "string"}]
634                }]
635            })))
636            .respond_with(ResponseTemplate::new(201).set_body_json(json!({"message": "success"})))
637            .mount(&server)
638            .await;
639
640        let client = make_client(&server);
641        let cat = Catalog {
642            name: "minimal".into(),
643            description: None,
644            fields: vec![CatalogField {
645                name: "id".into(),
646                field_type: CatalogFieldType::String,
647            }],
648        };
649        client.create_catalog(&cat).await.unwrap();
650    }
651
652    #[tokio::test]
653    async fn create_catalog_duplicate_name_propagates_400() {
654        let server = MockServer::start().await;
655        Mock::given(method("POST"))
656            .and(path("/catalogs"))
657            .respond_with(ResponseTemplate::new(400).set_body_json(json!({
658                "errors": [{
659                    "id": "catalog-name-already-exists",
660                    "message": "A catalog with that name already exists"
661                }]
662            })))
663            .mount(&server)
664            .await;
665
666        let client = make_client(&server);
667        let cat = Catalog {
668            name: "existing".into(),
669            description: None,
670            fields: vec![],
671        };
672        let err = client.create_catalog(&cat).await.unwrap_err();
673        assert!(
674            matches!(
675                &err,
676                BrazeApiError::Http { status, body }
677                    if *status == StatusCode::BAD_REQUEST
678                        && body.contains("catalog-name-already-exists")
679            ),
680            "got {err:?}"
681        );
682    }
683
684    #[tokio::test]
685    async fn create_catalog_unauthorized_propagates() {
686        let server = MockServer::start().await;
687        Mock::given(method("POST"))
688            .and(path("/catalogs"))
689            .respond_with(ResponseTemplate::new(401).set_body_string("invalid key"))
690            .mount(&server)
691            .await;
692
693        let client = make_client(&server);
694        let cat = Catalog {
695            name: "x".into(),
696            description: None,
697            fields: vec![],
698        };
699        let err = client.create_catalog(&cat).await.unwrap_err();
700        assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
701    }
702
703    #[tokio::test]
704    async fn create_catalog_retries_on_429_then_succeeds() {
705        let server = MockServer::start().await;
706        Mock::given(method("POST"))
707            .and(path("/catalogs"))
708            .respond_with(ResponseTemplate::new(201).set_body_json(json!({"message": "ok"})))
709            .mount(&server)
710            .await;
711        Mock::given(method("POST"))
712            .and(path("/catalogs"))
713            .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
714            .up_to_n_times(1)
715            .mount(&server)
716            .await;
717
718        let client = make_client(&server);
719        let cat = Catalog {
720            name: "x".into(),
721            description: None,
722            fields: vec![CatalogField {
723                name: "id".into(),
724                field_type: CatalogFieldType::String,
725            }],
726        };
727        client.create_catalog(&cat).await.unwrap();
728    }
729
730    #[tokio::test]
731    async fn delete_catalog_field_happy_path_uses_segment_encoded_path() {
732        let server = MockServer::start().await;
733        Mock::given(method("DELETE"))
734            .and(path("/catalogs/cardiology/fields/legacy_code"))
735            .and(header("authorization", "Bearer test-key"))
736            .respond_with(ResponseTemplate::new(204))
737            .mount(&server)
738            .await;
739
740        let client = make_client(&server);
741        client
742            .delete_catalog_field("cardiology", "legacy_code")
743            .await
744            .unwrap();
745    }
746
747    #[tokio::test]
748    async fn delete_catalog_field_server_error_returns_http() {
749        let server = MockServer::start().await;
750        Mock::given(method("DELETE"))
751            .and(path("/catalogs/cardiology/fields/x"))
752            .respond_with(ResponseTemplate::new(500).set_body_string("oops"))
753            .mount(&server)
754            .await;
755
756        let client = make_client(&server);
757        let err = client
758            .delete_catalog_field("cardiology", "x")
759            .await
760            .unwrap_err();
761        match err {
762            BrazeApiError::Http { status, body } => {
763                assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
764                assert!(body.contains("oops"));
765            }
766            other => panic!("expected Http, got {other:?}"),
767        }
768    }
769}