1use crate::braze::error::BrazeApiError;
4use crate::braze::BrazeClient;
5use crate::resource::{Catalog, CatalogField, CatalogFieldType};
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
28impl BrazeClient {
29 pub async fn list_catalogs(&self) -> Result<Vec<Catalog>, BrazeApiError> {
35 let req = self.get(&["catalogs"]);
36 let resp: CatalogsResponse = self.send_json(req).await?;
37 if let Some(cursor) = resp.next_cursor.as_deref() {
38 if !cursor.is_empty() {
39 return Err(BrazeApiError::PaginationNotImplemented {
40 endpoint: "/catalogs",
41 detail: format!(
42 "got {} catalog(s) plus a non-empty next_cursor; \
43 aborting to prevent silent truncation",
44 resp.catalogs.len()
45 ),
46 });
47 }
48 }
49 Ok(resp.catalogs)
50 }
51
52 pub async fn get_catalog(&self, name: &str) -> Result<Catalog, BrazeApiError> {
59 let req = self.get(&["catalogs", name]);
60 match self.send_json::<CatalogsResponse>(req).await {
61 Ok(resp) => resp
62 .catalogs
63 .into_iter()
64 .next()
65 .ok_or_else(|| BrazeApiError::NotFound {
66 resource: format!("catalog '{name}'"),
67 }),
68 Err(BrazeApiError::Http { status, .. }) if status == StatusCode::NOT_FOUND => {
69 Err(BrazeApiError::NotFound {
70 resource: format!("catalog '{name}'"),
71 })
72 }
73 Err(e) => Err(e),
74 }
75 }
76
77 pub async fn add_catalog_field(
83 &self,
84 catalog_name: &str,
85 field: &CatalogField,
86 ) -> Result<(), BrazeApiError> {
87 let body = AddFieldsRequest {
88 fields: vec![WireField {
89 name: &field.name,
90 field_type: field.field_type,
91 }],
92 };
93 let req = self.post(&["catalogs", catalog_name, "fields"]).json(&body);
94 self.send_ok(req).await
95 }
96
97 pub async fn delete_catalog_field(
106 &self,
107 catalog_name: &str,
108 field_name: &str,
109 ) -> Result<(), BrazeApiError> {
110 let req = self.delete(&["catalogs", catalog_name, "fields", field_name]);
111 self.send_ok(req).await
112 }
113}
114
115#[derive(Serialize)]
116struct AddFieldsRequest<'a> {
117 fields: Vec<WireField<'a>>,
118}
119
120#[derive(Serialize)]
121struct WireField<'a> {
122 name: &'a str,
123 #[serde(rename = "type")]
126 field_type: CatalogFieldType,
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132 use crate::braze::test_client as make_client;
133 use serde_json::json;
134 use wiremock::matchers::{body_json, header, method, path};
135 use wiremock::{Mock, MockServer, ResponseTemplate};
136
137 #[tokio::test]
138 async fn list_catalogs_happy_path() {
139 let server = MockServer::start().await;
140 Mock::given(method("GET"))
141 .and(path("/catalogs"))
142 .and(header("authorization", "Bearer test-key"))
143 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
144 "catalogs": [
145 {
146 "name": "cardiology",
147 "description": "Cardiology catalog",
148 "fields": [
149 {"name": "id", "type": "string"},
150 {"name": "score", "type": "number"}
151 ]
152 },
153 {
154 "name": "dermatology",
155 "fields": [
156 {"name": "id", "type": "string"}
157 ]
158 }
159 ],
160 "message": "success"
161 })))
162 .mount(&server)
163 .await;
164
165 let client = make_client(&server);
166 let cats = client.list_catalogs().await.unwrap();
167 assert_eq!(cats.len(), 2);
168 assert_eq!(cats[0].name, "cardiology");
169 assert_eq!(cats[0].description.as_deref(), Some("Cardiology catalog"));
170 assert_eq!(cats[0].fields.len(), 2);
171 assert_eq!(cats[0].fields[1].field_type, CatalogFieldType::Number);
172 assert_eq!(cats[1].name, "dermatology");
173 assert_eq!(cats[1].description, None);
174 }
175
176 #[tokio::test]
177 async fn list_catalogs_empty() {
178 let server = MockServer::start().await;
179 Mock::given(method("GET"))
180 .and(path("/catalogs"))
181 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"catalogs": []})))
182 .mount(&server)
183 .await;
184 let client = make_client(&server);
185 let cats = client.list_catalogs().await.unwrap();
186 assert!(cats.is_empty());
187 }
188
189 #[tokio::test]
190 async fn list_catalogs_sets_user_agent() {
191 let server = MockServer::start().await;
192 Mock::given(method("GET"))
193 .and(path("/catalogs"))
194 .and(header(
195 "user-agent",
196 concat!("braze-sync/", env!("CARGO_PKG_VERSION")),
197 ))
198 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"catalogs": []})))
199 .mount(&server)
200 .await;
201 let client = make_client(&server);
202 client.list_catalogs().await.unwrap();
203 }
204
205 #[tokio::test]
206 async fn list_catalogs_ignores_unknown_fields_in_response() {
207 let server = MockServer::start().await;
211 Mock::given(method("GET"))
212 .and(path("/catalogs"))
213 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
214 "catalogs": [
215 {
216 "name": "future",
217 "description": "tomorrow",
218 "future_metadata": {"foo": "bar"},
219 "num_items": 1234,
220 "fields": [
221 {"name": "id", "type": "string", "extra": "ignored"}
222 ]
223 }
224 ],
225 "future_top_level": {"whatever": true},
226 "message": "success"
227 })))
228 .mount(&server)
229 .await;
230 let client = make_client(&server);
231 let cats = client.list_catalogs().await.unwrap();
232 assert_eq!(cats.len(), 1);
233 assert_eq!(cats[0].name, "future");
234 }
235
236 #[tokio::test]
237 async fn list_catalogs_errors_when_next_cursor_present() {
238 let server = MockServer::start().await;
240 Mock::given(method("GET"))
241 .and(path("/catalogs"))
242 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
243 "catalogs": [
244 {"name": "cardiology", "fields": [{"name": "id", "type": "string"}]}
245 ],
246 "next_cursor": "abc123"
247 })))
248 .mount(&server)
249 .await;
250 let client = make_client(&server);
251 let err = client.list_catalogs().await.unwrap_err();
252 match err {
253 BrazeApiError::PaginationNotImplemented { endpoint, detail } => {
254 assert_eq!(endpoint, "/catalogs");
255 assert!(detail.contains("next_cursor"), "detail: {detail}");
256 assert!(detail.contains("1 catalog"), "detail: {detail}");
257 }
258 other => panic!("expected PaginationNotImplemented, got {other:?}"),
259 }
260 }
261
262 #[tokio::test]
263 async fn list_catalogs_empty_string_cursor_is_treated_as_no_more_pages() {
264 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": [{"name": "only", "fields": []}],
274 "next_cursor": ""
275 })))
276 .mount(&server)
277 .await;
278 let client = make_client(&server);
279 let cats = client.list_catalogs().await.unwrap();
280 assert_eq!(cats.len(), 1);
281 assert_eq!(cats[0].name, "only");
282 }
283
284 #[tokio::test]
285 async fn unauthorized_returns_typed_error() {
286 let server = MockServer::start().await;
287 Mock::given(method("GET"))
288 .and(path("/catalogs"))
289 .respond_with(ResponseTemplate::new(401).set_body_string("invalid api key"))
290 .mount(&server)
291 .await;
292 let client = make_client(&server);
293 let err = client.list_catalogs().await.unwrap_err();
294 assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
295 }
296
297 #[tokio::test]
298 async fn server_error_carries_status_and_body() {
299 let server = MockServer::start().await;
300 Mock::given(method("GET"))
301 .and(path("/catalogs"))
302 .respond_with(ResponseTemplate::new(500).set_body_string("internal explosion"))
303 .mount(&server)
304 .await;
305 let client = make_client(&server);
306 let err = client.list_catalogs().await.unwrap_err();
307 match err {
308 BrazeApiError::Http { status, body } => {
309 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
310 assert!(body.contains("internal explosion"));
311 }
312 other => panic!("expected Http, got {other:?}"),
313 }
314 }
315
316 #[tokio::test]
317 async fn retries_on_429_and_succeeds() {
318 let server = MockServer::start().await;
319 Mock::given(method("GET"))
323 .and(path("/catalogs"))
324 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
325 "catalogs": [{"name": "after_retry", "fields": []}]
326 })))
327 .mount(&server)
328 .await;
329 Mock::given(method("GET"))
330 .and(path("/catalogs"))
331 .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
332 .up_to_n_times(1)
333 .mount(&server)
334 .await;
335
336 let client = make_client(&server);
337 let cats = client.list_catalogs().await.unwrap();
338 assert_eq!(cats.len(), 1);
339 assert_eq!(cats[0].name, "after_retry");
340 }
341
342 #[tokio::test]
343 async fn retries_exhausted_returns_rate_limit_exhausted() {
344 let server = MockServer::start().await;
345 Mock::given(method("GET"))
346 .and(path("/catalogs"))
347 .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
348 .mount(&server)
349 .await;
350 let client = make_client(&server);
351 let err = client.list_catalogs().await.unwrap_err();
352 assert!(
353 matches!(err, BrazeApiError::RateLimitExhausted),
354 "got {err:?}"
355 );
356 }
357
358 #[tokio::test]
359 async fn get_catalog_happy_path() {
360 let server = MockServer::start().await;
361 Mock::given(method("GET"))
362 .and(path("/catalogs/cardiology"))
363 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
364 "catalogs": [
365 {"name": "cardiology", "fields": [{"name": "id", "type": "string"}]}
366 ]
367 })))
368 .mount(&server)
369 .await;
370 let client = make_client(&server);
371 let cat = client.get_catalog("cardiology").await.unwrap();
372 assert_eq!(cat.name, "cardiology");
373 assert_eq!(cat.fields.len(), 1);
374 }
375
376 #[tokio::test]
377 async fn get_catalog_404_is_mapped_to_not_found() {
378 let server = MockServer::start().await;
379 Mock::given(method("GET"))
380 .and(path("/catalogs/missing"))
381 .respond_with(ResponseTemplate::new(404).set_body_string("not found"))
382 .mount(&server)
383 .await;
384 let client = make_client(&server);
385 let err = client.get_catalog("missing").await.unwrap_err();
386 match err {
387 BrazeApiError::NotFound { resource } => assert!(resource.contains("missing")),
388 other => panic!("expected NotFound, got {other:?}"),
389 }
390 }
391
392 #[tokio::test]
393 async fn get_catalog_empty_response_array_is_not_found() {
394 let server = MockServer::start().await;
395 Mock::given(method("GET"))
396 .and(path("/catalogs/ghost"))
397 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"catalogs": []})))
398 .mount(&server)
399 .await;
400 let client = make_client(&server);
401 let err = client.get_catalog("ghost").await.unwrap_err();
402 assert!(matches!(err, BrazeApiError::NotFound { .. }), "got {err:?}");
403 }
404
405 #[tokio::test]
406 async fn debug_does_not_leak_api_key() {
407 let server = MockServer::start().await;
408 let client = make_client(&server);
409 let dbg = format!("{client:?}");
410 assert!(!dbg.contains("test-key"), "leaked api key in: {dbg}");
411 assert!(dbg.contains("<redacted>"));
412 }
413
414 #[tokio::test]
415 async fn add_catalog_field_happy_path_sends_correct_body() {
416 let server = MockServer::start().await;
417 Mock::given(method("POST"))
418 .and(path("/catalogs/cardiology/fields"))
419 .and(header("authorization", "Bearer test-key"))
420 .and(body_json(json!({
421 "fields": [{"name": "severity_level", "type": "number"}]
422 })))
423 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"message": "success"})))
424 .mount(&server)
425 .await;
426
427 let client = make_client(&server);
428 let field = CatalogField {
429 name: "severity_level".into(),
430 field_type: CatalogFieldType::Number,
431 };
432 client
433 .add_catalog_field("cardiology", &field)
434 .await
435 .unwrap();
436 }
437
438 #[tokio::test]
439 async fn add_catalog_field_unauthorized_propagates() {
440 let server = MockServer::start().await;
441 Mock::given(method("POST"))
442 .and(path("/catalogs/cardiology/fields"))
443 .respond_with(ResponseTemplate::new(401).set_body_string("invalid key"))
444 .mount(&server)
445 .await;
446
447 let client = make_client(&server);
448 let field = CatalogField {
449 name: "x".into(),
450 field_type: CatalogFieldType::String,
451 };
452 let err = client
453 .add_catalog_field("cardiology", &field)
454 .await
455 .unwrap_err();
456 assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
457 }
458
459 #[tokio::test]
460 async fn add_catalog_field_retries_on_429_then_succeeds() {
461 let server = MockServer::start().await;
462 Mock::given(method("POST"))
466 .and(path("/catalogs/cardiology/fields"))
467 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"message": "ok"})))
468 .mount(&server)
469 .await;
470 Mock::given(method("POST"))
471 .and(path("/catalogs/cardiology/fields"))
472 .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
473 .up_to_n_times(1)
474 .mount(&server)
475 .await;
476
477 let client = make_client(&server);
478 let field = CatalogField {
479 name: "x".into(),
480 field_type: CatalogFieldType::String,
481 };
482 client
483 .add_catalog_field("cardiology", &field)
484 .await
485 .unwrap();
486 }
487
488 #[tokio::test]
489 async fn delete_catalog_field_happy_path_uses_segment_encoded_path() {
490 let server = MockServer::start().await;
491 Mock::given(method("DELETE"))
492 .and(path("/catalogs/cardiology/fields/legacy_code"))
493 .and(header("authorization", "Bearer test-key"))
494 .respond_with(ResponseTemplate::new(204))
495 .mount(&server)
496 .await;
497
498 let client = make_client(&server);
499 client
500 .delete_catalog_field("cardiology", "legacy_code")
501 .await
502 .unwrap();
503 }
504
505 #[tokio::test]
506 async fn delete_catalog_field_server_error_returns_http() {
507 let server = MockServer::start().await;
508 Mock::given(method("DELETE"))
509 .and(path("/catalogs/cardiology/fields/x"))
510 .respond_with(ResponseTemplate::new(500).set_body_string("oops"))
511 .mount(&server)
512 .await;
513
514 let client = make_client(&server);
515 let err = client
516 .delete_catalog_field("cardiology", "x")
517 .await
518 .unwrap_err();
519 match err {
520 BrazeApiError::Http { status, body } => {
521 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
522 assert!(body.contains("oops"));
523 }
524 other => panic!("expected Http, got {other:?}"),
525 }
526 }
527}