Skip to main content

braze_sync/braze/
catalog.rs

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