1use crate::braze::error::BrazeApiError;
8use crate::braze::BrazeClient;
9use crate::resource::{Catalog, CatalogField, CatalogFieldType};
10use reqwest::StatusCode;
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Deserialize)]
22struct CatalogsResponse {
23 #[serde(default)]
24 catalogs: Vec<Catalog>,
25 #[serde(default)]
29 next_cursor: Option<String>,
30}
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
119#[derive(Serialize)]
120struct AddFieldsRequest<'a> {
121 fields: Vec<WireField<'a>>,
122}
123
124#[derive(Serialize)]
125struct WireField<'a> {
126 name: &'a str,
127 #[serde(rename = "type")]
130 field_type: CatalogFieldType,
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136 use crate::braze::test_client as make_client;
137 use serde_json::json;
138 use wiremock::matchers::{body_json, header, method, path};
139 use wiremock::{Mock, MockServer, ResponseTemplate};
140
141 #[tokio::test]
142 async fn list_catalogs_happy_path() {
143 let server = MockServer::start().await;
144 Mock::given(method("GET"))
145 .and(path("/catalogs"))
146 .and(header("authorization", "Bearer test-key"))
147 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
148 "catalogs": [
149 {
150 "name": "cardiology",
151 "description": "Cardiology catalog",
152 "fields": [
153 {"name": "id", "type": "string"},
154 {"name": "score", "type": "number"}
155 ]
156 },
157 {
158 "name": "dermatology",
159 "fields": [
160 {"name": "id", "type": "string"}
161 ]
162 }
163 ],
164 "message": "success"
165 })))
166 .mount(&server)
167 .await;
168
169 let client = make_client(&server);
170 let cats = client.list_catalogs().await.unwrap();
171 assert_eq!(cats.len(), 2);
172 assert_eq!(cats[0].name, "cardiology");
173 assert_eq!(cats[0].description.as_deref(), Some("Cardiology catalog"));
174 assert_eq!(cats[0].fields.len(), 2);
175 assert_eq!(cats[0].fields[1].field_type, CatalogFieldType::Number);
176 assert_eq!(cats[1].name, "dermatology");
177 assert_eq!(cats[1].description, None);
178 }
179
180 #[tokio::test]
181 async fn list_catalogs_empty() {
182 let server = MockServer::start().await;
183 Mock::given(method("GET"))
184 .and(path("/catalogs"))
185 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"catalogs": []})))
186 .mount(&server)
187 .await;
188 let client = make_client(&server);
189 let cats = client.list_catalogs().await.unwrap();
190 assert!(cats.is_empty());
191 }
192
193 #[tokio::test]
194 async fn list_catalogs_sets_user_agent() {
195 let server = MockServer::start().await;
196 Mock::given(method("GET"))
197 .and(path("/catalogs"))
198 .and(header(
199 "user-agent",
200 concat!("braze-sync/", env!("CARGO_PKG_VERSION")),
201 ))
202 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"catalogs": []})))
203 .mount(&server)
204 .await;
205 let client = make_client(&server);
206 client.list_catalogs().await.unwrap();
207 }
208
209 #[tokio::test]
210 async fn list_catalogs_ignores_unknown_fields_in_response() {
211 let server = MockServer::start().await;
215 Mock::given(method("GET"))
216 .and(path("/catalogs"))
217 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
218 "catalogs": [
219 {
220 "name": "future",
221 "description": "tomorrow",
222 "future_metadata": {"foo": "bar"},
223 "num_items": 1234,
224 "fields": [
225 {"name": "id", "type": "string", "extra": "ignored"}
226 ]
227 }
228 ],
229 "future_top_level": {"whatever": true},
230 "message": "success"
231 })))
232 .mount(&server)
233 .await;
234 let client = make_client(&server);
235 let cats = client.list_catalogs().await.unwrap();
236 assert_eq!(cats.len(), 1);
237 assert_eq!(cats[0].name, "future");
238 }
239
240 #[tokio::test]
241 async fn list_catalogs_errors_when_next_cursor_present() {
242 let server = MockServer::start().await;
244 Mock::given(method("GET"))
245 .and(path("/catalogs"))
246 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
247 "catalogs": [
248 {"name": "cardiology", "fields": [{"name": "id", "type": "string"}]}
249 ],
250 "next_cursor": "abc123"
251 })))
252 .mount(&server)
253 .await;
254 let client = make_client(&server);
255 let err = client.list_catalogs().await.unwrap_err();
256 match err {
257 BrazeApiError::PaginationNotImplemented { endpoint, detail } => {
258 assert_eq!(endpoint, "/catalogs");
259 assert!(detail.contains("next_cursor"), "detail: {detail}");
260 assert!(detail.contains("1 catalog"), "detail: {detail}");
261 }
262 other => panic!("expected PaginationNotImplemented, got {other:?}"),
263 }
264 }
265
266 #[tokio::test]
267 async fn list_catalogs_empty_string_cursor_is_treated_as_no_more_pages() {
268 let server = MockServer::start().await;
274 Mock::given(method("GET"))
275 .and(path("/catalogs"))
276 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
277 "catalogs": [{"name": "only", "fields": []}],
278 "next_cursor": ""
279 })))
280 .mount(&server)
281 .await;
282 let client = make_client(&server);
283 let cats = client.list_catalogs().await.unwrap();
284 assert_eq!(cats.len(), 1);
285 assert_eq!(cats[0].name, "only");
286 }
287
288 #[tokio::test]
289 async fn unauthorized_returns_typed_error() {
290 let server = MockServer::start().await;
291 Mock::given(method("GET"))
292 .and(path("/catalogs"))
293 .respond_with(ResponseTemplate::new(401).set_body_string("invalid api key"))
294 .mount(&server)
295 .await;
296 let client = make_client(&server);
297 let err = client.list_catalogs().await.unwrap_err();
298 assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
299 }
300
301 #[tokio::test]
302 async fn server_error_carries_status_and_body() {
303 let server = MockServer::start().await;
304 Mock::given(method("GET"))
305 .and(path("/catalogs"))
306 .respond_with(ResponseTemplate::new(500).set_body_string("internal explosion"))
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::Http { status, body } => {
313 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
314 assert!(body.contains("internal explosion"));
315 }
316 other => panic!("expected Http, got {other:?}"),
317 }
318 }
319
320 #[tokio::test]
321 async fn retries_on_429_and_succeeds() {
322 let server = MockServer::start().await;
323 Mock::given(method("GET"))
327 .and(path("/catalogs"))
328 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
329 "catalogs": [{"name": "after_retry", "fields": []}]
330 })))
331 .mount(&server)
332 .await;
333 Mock::given(method("GET"))
334 .and(path("/catalogs"))
335 .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
336 .up_to_n_times(1)
337 .mount(&server)
338 .await;
339
340 let client = make_client(&server);
341 let cats = client.list_catalogs().await.unwrap();
342 assert_eq!(cats.len(), 1);
343 assert_eq!(cats[0].name, "after_retry");
344 }
345
346 #[tokio::test]
347 async fn retries_exhausted_returns_rate_limit_exhausted() {
348 let server = MockServer::start().await;
349 Mock::given(method("GET"))
350 .and(path("/catalogs"))
351 .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
352 .mount(&server)
353 .await;
354 let client = make_client(&server);
355 let err = client.list_catalogs().await.unwrap_err();
356 assert!(
357 matches!(err, BrazeApiError::RateLimitExhausted),
358 "got {err:?}"
359 );
360 }
361
362 #[tokio::test]
363 async fn get_catalog_happy_path() {
364 let server = MockServer::start().await;
365 Mock::given(method("GET"))
366 .and(path("/catalogs/cardiology"))
367 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
368 "catalogs": [
369 {"name": "cardiology", "fields": [{"name": "id", "type": "string"}]}
370 ]
371 })))
372 .mount(&server)
373 .await;
374 let client = make_client(&server);
375 let cat = client.get_catalog("cardiology").await.unwrap();
376 assert_eq!(cat.name, "cardiology");
377 assert_eq!(cat.fields.len(), 1);
378 }
379
380 #[tokio::test]
381 async fn get_catalog_404_is_mapped_to_not_found() {
382 let server = MockServer::start().await;
383 Mock::given(method("GET"))
384 .and(path("/catalogs/missing"))
385 .respond_with(ResponseTemplate::new(404).set_body_string("not found"))
386 .mount(&server)
387 .await;
388 let client = make_client(&server);
389 let err = client.get_catalog("missing").await.unwrap_err();
390 match err {
391 BrazeApiError::NotFound { resource } => assert!(resource.contains("missing")),
392 other => panic!("expected NotFound, got {other:?}"),
393 }
394 }
395
396 #[tokio::test]
397 async fn get_catalog_empty_response_array_is_not_found() {
398 let server = MockServer::start().await;
399 Mock::given(method("GET"))
400 .and(path("/catalogs/ghost"))
401 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"catalogs": []})))
402 .mount(&server)
403 .await;
404 let client = make_client(&server);
405 let err = client.get_catalog("ghost").await.unwrap_err();
406 assert!(matches!(err, BrazeApiError::NotFound { .. }), "got {err:?}");
407 }
408
409 #[tokio::test]
410 async fn debug_does_not_leak_api_key() {
411 let server = MockServer::start().await;
412 let client = make_client(&server);
413 let dbg = format!("{client:?}");
414 assert!(!dbg.contains("test-key"), "leaked api key in: {dbg}");
415 assert!(dbg.contains("<redacted>"));
416 }
417
418 #[tokio::test]
419 async fn add_catalog_field_happy_path_sends_correct_body() {
420 let server = MockServer::start().await;
421 Mock::given(method("POST"))
422 .and(path("/catalogs/cardiology/fields"))
423 .and(header("authorization", "Bearer test-key"))
424 .and(body_json(json!({
425 "fields": [{"name": "severity_level", "type": "number"}]
426 })))
427 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"message": "success"})))
428 .mount(&server)
429 .await;
430
431 let client = make_client(&server);
432 let field = CatalogField {
433 name: "severity_level".into(),
434 field_type: CatalogFieldType::Number,
435 };
436 client
437 .add_catalog_field("cardiology", &field)
438 .await
439 .unwrap();
440 }
441
442 #[tokio::test]
443 async fn add_catalog_field_unauthorized_propagates() {
444 let server = MockServer::start().await;
445 Mock::given(method("POST"))
446 .and(path("/catalogs/cardiology/fields"))
447 .respond_with(ResponseTemplate::new(401).set_body_string("invalid key"))
448 .mount(&server)
449 .await;
450
451 let client = make_client(&server);
452 let field = CatalogField {
453 name: "x".into(),
454 field_type: CatalogFieldType::String,
455 };
456 let err = client
457 .add_catalog_field("cardiology", &field)
458 .await
459 .unwrap_err();
460 assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
461 }
462
463 #[tokio::test]
464 async fn add_catalog_field_retries_on_429_then_succeeds() {
465 let server = MockServer::start().await;
466 Mock::given(method("POST"))
470 .and(path("/catalogs/cardiology/fields"))
471 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"message": "ok"})))
472 .mount(&server)
473 .await;
474 Mock::given(method("POST"))
475 .and(path("/catalogs/cardiology/fields"))
476 .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
477 .up_to_n_times(1)
478 .mount(&server)
479 .await;
480
481 let client = make_client(&server);
482 let field = CatalogField {
483 name: "x".into(),
484 field_type: CatalogFieldType::String,
485 };
486 client
487 .add_catalog_field("cardiology", &field)
488 .await
489 .unwrap();
490 }
491
492 #[tokio::test]
493 async fn delete_catalog_field_happy_path_uses_segment_encoded_path() {
494 let server = MockServer::start().await;
495 Mock::given(method("DELETE"))
496 .and(path("/catalogs/cardiology/fields/legacy_code"))
497 .and(header("authorization", "Bearer test-key"))
498 .respond_with(ResponseTemplate::new(204))
499 .mount(&server)
500 .await;
501
502 let client = make_client(&server);
503 client
504 .delete_catalog_field("cardiology", "legacy_code")
505 .await
506 .unwrap();
507 }
508
509 #[tokio::test]
510 async fn delete_catalog_field_server_error_returns_http() {
511 let server = MockServer::start().await;
512 Mock::given(method("DELETE"))
513 .and(path("/catalogs/cardiology/fields/x"))
514 .respond_with(ResponseTemplate::new(500).set_body_string("oops"))
515 .mount(&server)
516 .await;
517
518 let client = make_client(&server);
519 let err = client
520 .delete_catalog_field("cardiology", "x")
521 .await
522 .unwrap_err();
523 match err {
524 BrazeApiError::Http { status, body } => {
525 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
526 assert!(body.contains("oops"));
527 }
528 other => panic!("expected Http, got {other:?}"),
529 }
530 }
531}