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 secrecy::SecretString;
128 use serde_json::json;
129 use url::Url;
130 use wiremock::matchers::{body_json, header, method, path};
131 use wiremock::{Mock, MockServer, ResponseTemplate};
132
133 fn make_client(server: &MockServer) -> BrazeClient {
134 BrazeClient::new(
135 Url::parse(&server.uri()).unwrap(),
136 SecretString::from("test-key".to_string()),
137 10_000,
139 )
140 }
141
142 #[tokio::test]
143 async fn list_catalogs_happy_path() {
144 let server = MockServer::start().await;
145 Mock::given(method("GET"))
146 .and(path("/catalogs"))
147 .and(header("authorization", "Bearer test-key"))
148 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
149 "catalogs": [
150 {
151 "name": "cardiology",
152 "description": "Cardiology catalog",
153 "fields": [
154 {"name": "id", "type": "string"},
155 {"name": "score", "type": "number"}
156 ]
157 },
158 {
159 "name": "dermatology",
160 "fields": [
161 {"name": "id", "type": "string"}
162 ]
163 }
164 ],
165 "message": "success"
166 })))
167 .mount(&server)
168 .await;
169
170 let client = make_client(&server);
171 let cats = client.list_catalogs().await.unwrap();
172 assert_eq!(cats.len(), 2);
173 assert_eq!(cats[0].name, "cardiology");
174 assert_eq!(cats[0].description.as_deref(), Some("Cardiology catalog"));
175 assert_eq!(cats[0].fields.len(), 2);
176 assert_eq!(cats[0].fields[1].field_type, CatalogFieldType::Number);
177 assert_eq!(cats[1].name, "dermatology");
178 assert_eq!(cats[1].description, None);
179 }
180
181 #[tokio::test]
182 async fn list_catalogs_empty() {
183 let server = MockServer::start().await;
184 Mock::given(method("GET"))
185 .and(path("/catalogs"))
186 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"catalogs": []})))
187 .mount(&server)
188 .await;
189 let client = make_client(&server);
190 let cats = client.list_catalogs().await.unwrap();
191 assert!(cats.is_empty());
192 }
193
194 #[tokio::test]
195 async fn list_catalogs_sets_user_agent() {
196 let server = MockServer::start().await;
197 Mock::given(method("GET"))
198 .and(path("/catalogs"))
199 .and(header(
200 "user-agent",
201 concat!("braze-sync/", env!("CARGO_PKG_VERSION")),
202 ))
203 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"catalogs": []})))
204 .mount(&server)
205 .await;
206 let client = make_client(&server);
207 client.list_catalogs().await.unwrap();
208 }
209
210 #[tokio::test]
211 async fn list_catalogs_ignores_unknown_fields_in_response() {
212 let server = MockServer::start().await;
217 Mock::given(method("GET"))
218 .and(path("/catalogs"))
219 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
220 "catalogs": [
221 {
222 "name": "future",
223 "description": "tomorrow",
224 "future_metadata": {"foo": "bar"},
225 "num_items": 1234,
226 "fields": [
227 {"name": "id", "type": "string", "extra": "ignored"}
228 ]
229 }
230 ],
231 "next_cursor": "abc",
232 "message": "success"
233 })))
234 .mount(&server)
235 .await;
236 let client = make_client(&server);
237 let cats = client.list_catalogs().await.unwrap();
238 assert_eq!(cats.len(), 1);
239 assert_eq!(cats[0].name, "future");
240 }
241
242 #[tokio::test]
243 async fn unauthorized_returns_typed_error() {
244 let server = MockServer::start().await;
245 Mock::given(method("GET"))
246 .and(path("/catalogs"))
247 .respond_with(ResponseTemplate::new(401).set_body_string("invalid api key"))
248 .mount(&server)
249 .await;
250 let client = make_client(&server);
251 let err = client.list_catalogs().await.unwrap_err();
252 assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
253 }
254
255 #[tokio::test]
256 async fn server_error_carries_status_and_body() {
257 let server = MockServer::start().await;
258 Mock::given(method("GET"))
259 .and(path("/catalogs"))
260 .respond_with(ResponseTemplate::new(500).set_body_string("internal explosion"))
261 .mount(&server)
262 .await;
263 let client = make_client(&server);
264 let err = client.list_catalogs().await.unwrap_err();
265 match err {
266 BrazeApiError::Http { status, body } => {
267 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
268 assert!(body.contains("internal explosion"));
269 }
270 other => panic!("expected Http, got {other:?}"),
271 }
272 }
273
274 #[tokio::test]
275 async fn retries_on_429_and_succeeds() {
276 let server = MockServer::start().await;
277 Mock::given(method("GET"))
281 .and(path("/catalogs"))
282 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
283 "catalogs": [{"name": "after_retry", "fields": []}]
284 })))
285 .mount(&server)
286 .await;
287 Mock::given(method("GET"))
288 .and(path("/catalogs"))
289 .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
290 .up_to_n_times(1)
291 .mount(&server)
292 .await;
293
294 let client = make_client(&server);
295 let cats = client.list_catalogs().await.unwrap();
296 assert_eq!(cats.len(), 1);
297 assert_eq!(cats[0].name, "after_retry");
298 }
299
300 #[tokio::test]
301 async fn retries_exhausted_returns_rate_limit_exhausted() {
302 let server = MockServer::start().await;
303 Mock::given(method("GET"))
304 .and(path("/catalogs"))
305 .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
306 .mount(&server)
307 .await;
308 let client = make_client(&server);
309 let err = client.list_catalogs().await.unwrap_err();
310 assert!(
311 matches!(err, BrazeApiError::RateLimitExhausted),
312 "got {err:?}"
313 );
314 }
315
316 #[tokio::test]
317 async fn get_catalog_happy_path() {
318 let server = MockServer::start().await;
319 Mock::given(method("GET"))
320 .and(path("/catalogs/cardiology"))
321 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
322 "catalogs": [
323 {"name": "cardiology", "fields": [{"name": "id", "type": "string"}]}
324 ]
325 })))
326 .mount(&server)
327 .await;
328 let client = make_client(&server);
329 let cat = client.get_catalog("cardiology").await.unwrap();
330 assert_eq!(cat.name, "cardiology");
331 assert_eq!(cat.fields.len(), 1);
332 }
333
334 #[tokio::test]
335 async fn get_catalog_404_is_mapped_to_not_found() {
336 let server = MockServer::start().await;
337 Mock::given(method("GET"))
338 .and(path("/catalogs/missing"))
339 .respond_with(ResponseTemplate::new(404).set_body_string("not found"))
340 .mount(&server)
341 .await;
342 let client = make_client(&server);
343 let err = client.get_catalog("missing").await.unwrap_err();
344 match err {
345 BrazeApiError::NotFound { resource } => assert!(resource.contains("missing")),
346 other => panic!("expected NotFound, got {other:?}"),
347 }
348 }
349
350 #[tokio::test]
351 async fn get_catalog_empty_response_array_is_not_found() {
352 let server = MockServer::start().await;
353 Mock::given(method("GET"))
354 .and(path("/catalogs/ghost"))
355 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"catalogs": []})))
356 .mount(&server)
357 .await;
358 let client = make_client(&server);
359 let err = client.get_catalog("ghost").await.unwrap_err();
360 assert!(matches!(err, BrazeApiError::NotFound { .. }), "got {err:?}");
361 }
362
363 #[tokio::test]
364 async fn debug_does_not_leak_api_key() {
365 let server = MockServer::start().await;
366 let client = make_client(&server);
367 let dbg = format!("{client:?}");
368 assert!(!dbg.contains("test-key"), "leaked api key in: {dbg}");
369 assert!(dbg.contains("<redacted>"));
370 }
371
372 #[tokio::test]
373 async fn add_catalog_field_happy_path_sends_correct_body() {
374 let server = MockServer::start().await;
375 Mock::given(method("POST"))
376 .and(path("/catalogs/cardiology/fields"))
377 .and(header("authorization", "Bearer test-key"))
378 .and(body_json(json!({
379 "fields": [{"name": "severity_level", "type": "number"}]
380 })))
381 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"message": "success"})))
382 .mount(&server)
383 .await;
384
385 let client = make_client(&server);
386 let field = CatalogField {
387 name: "severity_level".into(),
388 field_type: CatalogFieldType::Number,
389 };
390 client
391 .add_catalog_field("cardiology", &field)
392 .await
393 .unwrap();
394 }
395
396 #[tokio::test]
397 async fn add_catalog_field_unauthorized_propagates() {
398 let server = MockServer::start().await;
399 Mock::given(method("POST"))
400 .and(path("/catalogs/cardiology/fields"))
401 .respond_with(ResponseTemplate::new(401).set_body_string("invalid key"))
402 .mount(&server)
403 .await;
404
405 let client = make_client(&server);
406 let field = CatalogField {
407 name: "x".into(),
408 field_type: CatalogFieldType::String,
409 };
410 let err = client
411 .add_catalog_field("cardiology", &field)
412 .await
413 .unwrap_err();
414 assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
415 }
416
417 #[tokio::test]
418 async fn add_catalog_field_retries_on_429_then_succeeds() {
419 let server = MockServer::start().await;
420 Mock::given(method("POST"))
424 .and(path("/catalogs/cardiology/fields"))
425 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"message": "ok"})))
426 .mount(&server)
427 .await;
428 Mock::given(method("POST"))
429 .and(path("/catalogs/cardiology/fields"))
430 .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
431 .up_to_n_times(1)
432 .mount(&server)
433 .await;
434
435 let client = make_client(&server);
436 let field = CatalogField {
437 name: "x".into(),
438 field_type: CatalogFieldType::String,
439 };
440 client
441 .add_catalog_field("cardiology", &field)
442 .await
443 .unwrap();
444 }
445
446 #[tokio::test]
447 async fn delete_catalog_field_happy_path_uses_segment_encoded_path() {
448 let server = MockServer::start().await;
449 Mock::given(method("DELETE"))
450 .and(path("/catalogs/cardiology/fields/legacy_code"))
451 .and(header("authorization", "Bearer test-key"))
452 .respond_with(ResponseTemplate::new(204))
453 .mount(&server)
454 .await;
455
456 let client = make_client(&server);
457 client
458 .delete_catalog_field("cardiology", "legacy_code")
459 .await
460 .unwrap();
461 }
462
463 #[tokio::test]
464 async fn delete_catalog_field_server_error_returns_http() {
465 let server = MockServer::start().await;
466 Mock::given(method("DELETE"))
467 .and(path("/catalogs/cardiology/fields/x"))
468 .respond_with(ResponseTemplate::new(500).set_body_string("oops"))
469 .mount(&server)
470 .await;
471
472 let client = make_client(&server);
473 let err = client
474 .delete_catalog_field("cardiology", "x")
475 .await
476 .unwrap_err();
477 match err {
478 BrazeApiError::Http { status, body } => {
479 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
480 assert!(body.contains("oops"));
481 }
482 other => panic!("expected Http, got {other:?}"),
483 }
484 }
485}