Skip to main content

braze_sync/braze/
catalog.rs

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