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)]
24 next_cursor: Option<String>,
25}
26
27impl BrazeClient {
28 pub async fn list_catalogs(&self) -> Result<Vec<Catalog>, BrazeApiError> {
34 let req = self.get(&["catalogs"]);
35 let resp: CatalogsResponse = self.send_json(req).await?;
36 if let Some(cursor) = &resp.next_cursor {
37 if !cursor.is_empty() {
38 tracing::warn!(
39 "Braze returned a pagination cursor; only the first page \
40 of catalogs was fetched (pagination is not yet implemented)"
41 );
42 }
43 }
44 Ok(resp.catalogs)
45 }
46
47 pub async fn get_catalog(&self, name: &str) -> Result<Catalog, BrazeApiError> {
54 let req = self.get(&["catalogs", name]);
55 match self.send_json::<CatalogsResponse>(req).await {
56 Ok(resp) => resp
57 .catalogs
58 .into_iter()
59 .next()
60 .ok_or_else(|| BrazeApiError::NotFound {
61 resource: format!("catalog '{name}'"),
62 }),
63 Err(BrazeApiError::Http { status, .. }) if status == StatusCode::NOT_FOUND => {
64 Err(BrazeApiError::NotFound {
65 resource: format!("catalog '{name}'"),
66 })
67 }
68 Err(e) => Err(e),
69 }
70 }
71
72 pub async fn add_catalog_field(
78 &self,
79 catalog_name: &str,
80 field: &CatalogField,
81 ) -> Result<(), BrazeApiError> {
82 let body = AddFieldsRequest {
83 fields: vec![WireField {
84 name: &field.name,
85 field_type: field.field_type,
86 }],
87 };
88 let req = self.post(&["catalogs", catalog_name, "fields"]).json(&body);
89 self.send_ok(req).await
90 }
91
92 pub async fn delete_catalog_field(
101 &self,
102 catalog_name: &str,
103 field_name: &str,
104 ) -> Result<(), BrazeApiError> {
105 let req = self.delete(&["catalogs", catalog_name, "fields", field_name]);
106 self.send_ok(req).await
107 }
108}
109
110#[derive(Serialize)]
111struct AddFieldsRequest<'a> {
112 fields: Vec<WireField<'a>>,
113}
114
115#[derive(Serialize)]
116struct WireField<'a> {
117 name: &'a str,
118 #[serde(rename = "type")]
121 field_type: CatalogFieldType,
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127 use crate::braze::test_client as make_client;
128 use serde_json::json;
129 use wiremock::matchers::{body_json, header, method, path};
130 use wiremock::{Mock, MockServer, ResponseTemplate};
131
132 #[tokio::test]
133 async fn list_catalogs_happy_path() {
134 let server = MockServer::start().await;
135 Mock::given(method("GET"))
136 .and(path("/catalogs"))
137 .and(header("authorization", "Bearer test-key"))
138 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
139 "catalogs": [
140 {
141 "name": "cardiology",
142 "description": "Cardiology catalog",
143 "fields": [
144 {"name": "id", "type": "string"},
145 {"name": "score", "type": "number"}
146 ]
147 },
148 {
149 "name": "dermatology",
150 "fields": [
151 {"name": "id", "type": "string"}
152 ]
153 }
154 ],
155 "message": "success"
156 })))
157 .mount(&server)
158 .await;
159
160 let client = make_client(&server);
161 let cats = client.list_catalogs().await.unwrap();
162 assert_eq!(cats.len(), 2);
163 assert_eq!(cats[0].name, "cardiology");
164 assert_eq!(cats[0].description.as_deref(), Some("Cardiology catalog"));
165 assert_eq!(cats[0].fields.len(), 2);
166 assert_eq!(cats[0].fields[1].field_type, CatalogFieldType::Number);
167 assert_eq!(cats[1].name, "dermatology");
168 assert_eq!(cats[1].description, None);
169 }
170
171 #[tokio::test]
172 async fn list_catalogs_empty() {
173 let server = MockServer::start().await;
174 Mock::given(method("GET"))
175 .and(path("/catalogs"))
176 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"catalogs": []})))
177 .mount(&server)
178 .await;
179 let client = make_client(&server);
180 let cats = client.list_catalogs().await.unwrap();
181 assert!(cats.is_empty());
182 }
183
184 #[tokio::test]
185 async fn list_catalogs_sets_user_agent() {
186 let server = MockServer::start().await;
187 Mock::given(method("GET"))
188 .and(path("/catalogs"))
189 .and(header(
190 "user-agent",
191 concat!("braze-sync/", env!("CARGO_PKG_VERSION")),
192 ))
193 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"catalogs": []})))
194 .mount(&server)
195 .await;
196 let client = make_client(&server);
197 client.list_catalogs().await.unwrap();
198 }
199
200 #[tokio::test]
201 async fn list_catalogs_ignores_unknown_fields_in_response() {
202 let server = MockServer::start().await;
207 Mock::given(method("GET"))
208 .and(path("/catalogs"))
209 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
210 "catalogs": [
211 {
212 "name": "future",
213 "description": "tomorrow",
214 "future_metadata": {"foo": "bar"},
215 "num_items": 1234,
216 "fields": [
217 {"name": "id", "type": "string", "extra": "ignored"}
218 ]
219 }
220 ],
221 "next_cursor": "abc",
222 "message": "success"
223 })))
224 .mount(&server)
225 .await;
226 let client = make_client(&server);
227 let cats = client.list_catalogs().await.unwrap();
228 assert_eq!(cats.len(), 1);
229 assert_eq!(cats[0].name, "future");
230 }
231
232 #[tokio::test]
233 async fn unauthorized_returns_typed_error() {
234 let server = MockServer::start().await;
235 Mock::given(method("GET"))
236 .and(path("/catalogs"))
237 .respond_with(ResponseTemplate::new(401).set_body_string("invalid api key"))
238 .mount(&server)
239 .await;
240 let client = make_client(&server);
241 let err = client.list_catalogs().await.unwrap_err();
242 assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
243 }
244
245 #[tokio::test]
246 async fn server_error_carries_status_and_body() {
247 let server = MockServer::start().await;
248 Mock::given(method("GET"))
249 .and(path("/catalogs"))
250 .respond_with(ResponseTemplate::new(500).set_body_string("internal explosion"))
251 .mount(&server)
252 .await;
253 let client = make_client(&server);
254 let err = client.list_catalogs().await.unwrap_err();
255 match err {
256 BrazeApiError::Http { status, body } => {
257 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
258 assert!(body.contains("internal explosion"));
259 }
260 other => panic!("expected Http, got {other:?}"),
261 }
262 }
263
264 #[tokio::test]
265 async fn retries_on_429_and_succeeds() {
266 let server = MockServer::start().await;
267 Mock::given(method("GET"))
271 .and(path("/catalogs"))
272 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
273 "catalogs": [{"name": "after_retry", "fields": []}]
274 })))
275 .mount(&server)
276 .await;
277 Mock::given(method("GET"))
278 .and(path("/catalogs"))
279 .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
280 .up_to_n_times(1)
281 .mount(&server)
282 .await;
283
284 let client = make_client(&server);
285 let cats = client.list_catalogs().await.unwrap();
286 assert_eq!(cats.len(), 1);
287 assert_eq!(cats[0].name, "after_retry");
288 }
289
290 #[tokio::test]
291 async fn retries_exhausted_returns_rate_limit_exhausted() {
292 let server = MockServer::start().await;
293 Mock::given(method("GET"))
294 .and(path("/catalogs"))
295 .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
296 .mount(&server)
297 .await;
298 let client = make_client(&server);
299 let err = client.list_catalogs().await.unwrap_err();
300 assert!(
301 matches!(err, BrazeApiError::RateLimitExhausted),
302 "got {err:?}"
303 );
304 }
305
306 #[tokio::test]
307 async fn get_catalog_happy_path() {
308 let server = MockServer::start().await;
309 Mock::given(method("GET"))
310 .and(path("/catalogs/cardiology"))
311 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
312 "catalogs": [
313 {"name": "cardiology", "fields": [{"name": "id", "type": "string"}]}
314 ]
315 })))
316 .mount(&server)
317 .await;
318 let client = make_client(&server);
319 let cat = client.get_catalog("cardiology").await.unwrap();
320 assert_eq!(cat.name, "cardiology");
321 assert_eq!(cat.fields.len(), 1);
322 }
323
324 #[tokio::test]
325 async fn get_catalog_404_is_mapped_to_not_found() {
326 let server = MockServer::start().await;
327 Mock::given(method("GET"))
328 .and(path("/catalogs/missing"))
329 .respond_with(ResponseTemplate::new(404).set_body_string("not found"))
330 .mount(&server)
331 .await;
332 let client = make_client(&server);
333 let err = client.get_catalog("missing").await.unwrap_err();
334 match err {
335 BrazeApiError::NotFound { resource } => assert!(resource.contains("missing")),
336 other => panic!("expected NotFound, got {other:?}"),
337 }
338 }
339
340 #[tokio::test]
341 async fn get_catalog_empty_response_array_is_not_found() {
342 let server = MockServer::start().await;
343 Mock::given(method("GET"))
344 .and(path("/catalogs/ghost"))
345 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"catalogs": []})))
346 .mount(&server)
347 .await;
348 let client = make_client(&server);
349 let err = client.get_catalog("ghost").await.unwrap_err();
350 assert!(matches!(err, BrazeApiError::NotFound { .. }), "got {err:?}");
351 }
352
353 #[tokio::test]
354 async fn debug_does_not_leak_api_key() {
355 let server = MockServer::start().await;
356 let client = make_client(&server);
357 let dbg = format!("{client:?}");
358 assert!(!dbg.contains("test-key"), "leaked api key in: {dbg}");
359 assert!(dbg.contains("<redacted>"));
360 }
361
362 #[tokio::test]
363 async fn add_catalog_field_happy_path_sends_correct_body() {
364 let server = MockServer::start().await;
365 Mock::given(method("POST"))
366 .and(path("/catalogs/cardiology/fields"))
367 .and(header("authorization", "Bearer test-key"))
368 .and(body_json(json!({
369 "fields": [{"name": "severity_level", "type": "number"}]
370 })))
371 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"message": "success"})))
372 .mount(&server)
373 .await;
374
375 let client = make_client(&server);
376 let field = CatalogField {
377 name: "severity_level".into(),
378 field_type: CatalogFieldType::Number,
379 };
380 client
381 .add_catalog_field("cardiology", &field)
382 .await
383 .unwrap();
384 }
385
386 #[tokio::test]
387 async fn add_catalog_field_unauthorized_propagates() {
388 let server = MockServer::start().await;
389 Mock::given(method("POST"))
390 .and(path("/catalogs/cardiology/fields"))
391 .respond_with(ResponseTemplate::new(401).set_body_string("invalid key"))
392 .mount(&server)
393 .await;
394
395 let client = make_client(&server);
396 let field = CatalogField {
397 name: "x".into(),
398 field_type: CatalogFieldType::String,
399 };
400 let err = client
401 .add_catalog_field("cardiology", &field)
402 .await
403 .unwrap_err();
404 assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
405 }
406
407 #[tokio::test]
408 async fn add_catalog_field_retries_on_429_then_succeeds() {
409 let server = MockServer::start().await;
410 Mock::given(method("POST"))
414 .and(path("/catalogs/cardiology/fields"))
415 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"message": "ok"})))
416 .mount(&server)
417 .await;
418 Mock::given(method("POST"))
419 .and(path("/catalogs/cardiology/fields"))
420 .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
421 .up_to_n_times(1)
422 .mount(&server)
423 .await;
424
425 let client = make_client(&server);
426 let field = CatalogField {
427 name: "x".into(),
428 field_type: CatalogFieldType::String,
429 };
430 client
431 .add_catalog_field("cardiology", &field)
432 .await
433 .unwrap();
434 }
435
436 #[tokio::test]
437 async fn delete_catalog_field_happy_path_uses_segment_encoded_path() {
438 let server = MockServer::start().await;
439 Mock::given(method("DELETE"))
440 .and(path("/catalogs/cardiology/fields/legacy_code"))
441 .and(header("authorization", "Bearer test-key"))
442 .respond_with(ResponseTemplate::new(204))
443 .mount(&server)
444 .await;
445
446 let client = make_client(&server);
447 client
448 .delete_catalog_field("cardiology", "legacy_code")
449 .await
450 .unwrap();
451 }
452
453 #[tokio::test]
454 async fn delete_catalog_field_server_error_returns_http() {
455 let server = MockServer::start().await;
456 Mock::given(method("DELETE"))
457 .and(path("/catalogs/cardiology/fields/x"))
458 .respond_with(ResponseTemplate::new(500).set_body_string("oops"))
459 .mount(&server)
460 .await;
461
462 let client = make_client(&server);
463 let err = client
464 .delete_catalog_field("cardiology", "x")
465 .await
466 .unwrap_err();
467 match err {
468 BrazeApiError::Http { status, body } => {
469 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
470 assert!(body.contains("oops"));
471 }
472 other => panic!("expected Http, got {other:?}"),
473 }
474 }
475}