1use crate::braze::error::BrazeApiError;
4use crate::braze::BrazeClient;
5use crate::resource::{Catalog, CatalogField, CatalogFieldType, CatalogItemRow};
6use reqwest::StatusCode;
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Deserialize)]
18struct CatalogsResponse {
19 #[serde(default)]
20 catalogs: Vec<Catalog>,
21 #[serde(default)]
25 next_cursor: Option<String>,
26}
27
28const MAX_CATALOG_ITEM_PAGES: usize = 2_000;
31
32impl BrazeClient {
33 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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 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}