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