mod common;
use assert_cmd::Command;
use common::{
write_config, write_local_content_block, write_local_email_template, write_local_schema,
};
use serde_json::json;
use wiremock::matchers::{body_json, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn dry_run_makes_no_write_calls_and_exits_zero() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/catalogs"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"catalogs": [
{"name": "cardiology", "fields": [{"name": "id", "type": "string"}]}
]
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(500))
.expect(0)
.mount(&server)
.await;
Mock::given(method("DELETE"))
.respond_with(ResponseTemplate::new(500))
.expect(0)
.mount(&server)
.await;
let tmp = tempfile::tempdir().unwrap();
let config_path = write_config(tmp.path(), &server.uri());
write_local_schema(
tmp.path(),
"cardiology",
&[("id", "string"), ("severity", "number")],
);
tokio::task::spawn_blocking(move || {
Command::cargo_bin("braze-sync")
.unwrap()
.env("BRAZE_API_KEY", "test-key")
.args(["--config", config_path.to_str().unwrap()])
.args(["apply", "--resource", "catalog_schema"])
.assert()
.success();
})
.await
.unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn confirm_with_field_addition_calls_post_and_exits_zero() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/catalogs"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"catalogs": [
{"name": "cardiology", "fields": [{"name": "id", "type": "string"}]}
]
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/catalogs/cardiology/fields"))
.and(body_json(json!({
"fields": [{"name": "severity", "type": "number"}]
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"message": "ok"})))
.expect(1)
.mount(&server)
.await;
let tmp = tempfile::tempdir().unwrap();
let config_path = write_config(tmp.path(), &server.uri());
write_local_schema(
tmp.path(),
"cardiology",
&[("id", "string"), ("severity", "number")],
);
tokio::task::spawn_blocking(move || {
Command::cargo_bin("braze-sync")
.unwrap()
.env("BRAZE_API_KEY", "test-key")
.args(["--config", config_path.to_str().unwrap()])
.args(["apply", "--resource", "catalog_schema", "--confirm"])
.assert()
.success();
})
.await
.unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn confirm_with_destructive_change_without_allow_destructive_exits_6() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/catalogs"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"catalogs": [
{"name": "cardiology", "fields": [
{"name": "id", "type": "string"},
{"name": "legacy", "type": "string"}
]}
]
})))
.mount(&server)
.await;
Mock::given(method("DELETE"))
.respond_with(ResponseTemplate::new(204))
.expect(0)
.mount(&server)
.await;
let tmp = tempfile::tempdir().unwrap();
let config_path = write_config(tmp.path(), &server.uri());
write_local_schema(tmp.path(), "cardiology", &[("id", "string")]);
tokio::task::spawn_blocking(move || {
Command::cargo_bin("braze-sync")
.unwrap()
.env("BRAZE_API_KEY", "test-key")
.args(["--config", config_path.to_str().unwrap()])
.args(["apply", "--resource", "catalog_schema", "--confirm"])
.assert()
.failure()
.code(6);
})
.await
.unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn dry_run_with_json_format_emits_valid_v1_json() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/catalogs"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"catalogs": [
{"name": "cardiology", "fields": [{"name": "id", "type": "string"}]}
]
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(500))
.expect(0)
.mount(&server)
.await;
let tmp = tempfile::tempdir().unwrap();
let config_path = write_config(tmp.path(), &server.uri());
write_local_schema(
tmp.path(),
"cardiology",
&[("id", "string"), ("severity", "number")],
);
let output = tokio::task::spawn_blocking(move || {
Command::cargo_bin("braze-sync")
.unwrap()
.env("BRAZE_API_KEY", "test-key")
.args(["--format", "json"])
.args(["--config", config_path.to_str().unwrap()])
.args(["apply", "--resource", "catalog_schema"])
.output()
.unwrap()
})
.await
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).unwrap();
let v: serde_json::Value = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("invalid json: {e}; got: {stdout}"));
assert_eq!(v["version"], json!(1));
assert_eq!(v["summary"]["changed"], json!(1));
assert_eq!(v["diffs"][0]["kind"], "catalog_schema");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn confirm_with_allow_destructive_calls_delete_and_exits_zero() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/catalogs"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"catalogs": [
{"name": "cardiology", "fields": [
{"name": "id", "type": "string"},
{"name": "legacy", "type": "string"}
]}
]
})))
.mount(&server)
.await;
Mock::given(method("DELETE"))
.and(path("/catalogs/cardiology/fields/legacy"))
.respond_with(ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
let tmp = tempfile::tempdir().unwrap();
let config_path = write_config(tmp.path(), &server.uri());
write_local_schema(tmp.path(), "cardiology", &[("id", "string")]);
tokio::task::spawn_blocking(move || {
Command::cargo_bin("braze-sync")
.unwrap()
.env("BRAZE_API_KEY", "test-key")
.args(["--config", config_path.to_str().unwrap()])
.args([
"apply",
"--resource",
"catalog_schema",
"--confirm",
"--allow-destructive",
])
.assert()
.success();
})
.await
.unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn content_block_dry_run_makes_no_write_calls() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/content_blocks/list"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"content_blocks": []
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(500))
.expect(0)
.mount(&server)
.await;
let tmp = tempfile::tempdir().unwrap();
let config_path = write_config(tmp.path(), &server.uri());
write_local_content_block(tmp.path(), "fresh", "Hello\n");
tokio::task::spawn_blocking(move || {
Command::cargo_bin("braze-sync")
.unwrap()
.env("BRAZE_API_KEY", "test-key")
.args(["--config", config_path.to_str().unwrap()])
.args(["apply", "--resource", "content_block"])
.assert()
.success();
})
.await
.unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn content_block_confirm_create_posts_to_create_endpoint() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/content_blocks/list"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"content_blocks": []
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/content_blocks/create"))
.and(body_json(json!({
"name": "fresh",
"content": "Hello\n",
"tags": [],
"state": "active"
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"content_block_id": "new-id-1",
"message": "success"
})))
.expect(1)
.mount(&server)
.await;
let tmp = tempfile::tempdir().unwrap();
let config_path = write_config(tmp.path(), &server.uri());
write_local_content_block(tmp.path(), "fresh", "Hello\n");
tokio::task::spawn_blocking(move || {
Command::cargo_bin("braze-sync")
.unwrap()
.env("BRAZE_API_KEY", "test-key")
.args(["--config", config_path.to_str().unwrap()])
.args(["apply", "--resource", "content_block", "--confirm"])
.assert()
.success();
})
.await
.unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn content_block_confirm_update_posts_to_update_endpoint_with_id() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/content_blocks/list"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"content_blocks": [{"content_block_id": "id-promo", "name": "promo"}]
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/content_blocks/info"))
.and(query_param("content_block_id", "id-promo"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"name": "promo",
"content": "old body\n",
"tags": []
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/content_blocks/update"))
.and(body_json(json!({
"content_block_id": "id-promo",
"name": "promo",
"content": "new body\n",
"tags": []
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"message": "success"})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/content_blocks/create"))
.respond_with(ResponseTemplate::new(500))
.expect(0)
.mount(&server)
.await;
let tmp = tempfile::tempdir().unwrap();
let config_path = write_config(tmp.path(), &server.uri());
write_local_content_block(tmp.path(), "promo", "new body\n");
tokio::task::spawn_blocking(move || {
Command::cargo_bin("braze-sync")
.unwrap()
.env("BRAZE_API_KEY", "test-key")
.args(["--config", config_path.to_str().unwrap()])
.args(["apply", "--resource", "content_block", "--confirm"])
.assert()
.success();
})
.await
.unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn content_block_orphan_without_archive_flag_makes_no_write_calls() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/content_blocks/list"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"content_blocks": [{"content_block_id": "id-orphan", "name": "legacy"}]
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/content_blocks/info"))
.respond_with(ResponseTemplate::new(500))
.expect(0)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(500))
.expect(0)
.mount(&server)
.await;
let tmp = tempfile::tempdir().unwrap();
let config_path = write_config(tmp.path(), &server.uri());
tokio::task::spawn_blocking(move || {
Command::cargo_bin("braze-sync")
.unwrap()
.env("BRAZE_API_KEY", "test-key")
.args(["--config", config_path.to_str().unwrap()])
.args([
"apply",
"--resource",
"content_block",
"--confirm",
"--allow-destructive",
])
.assert()
.success();
})
.await
.unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn content_block_apply_aborts_on_paginated_list_response() {
let server = MockServer::start().await;
let entries: Vec<serde_json::Value> = (0..100)
.map(|i| {
json!({
"content_block_id": format!("id-{i}"),
"name": format!("block-{i}")
})
})
.collect();
Mock::given(method("GET"))
.and(path("/content_blocks/list"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"count": 250,
"content_blocks": entries
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(500))
.expect(0)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/content_blocks/info"))
.respond_with(ResponseTemplate::new(500))
.expect(0)
.mount(&server)
.await;
let tmp = tempfile::tempdir().unwrap();
let config_path = write_config(tmp.path(), &server.uri());
write_local_content_block(tmp.path(), "block-150", "body\n");
let output = tokio::task::spawn_blocking(move || {
Command::cargo_bin("braze-sync")
.unwrap()
.env("BRAZE_API_KEY", "test-key")
.args(["--config", config_path.to_str().unwrap()])
.args(["apply", "--resource", "content_block", "--confirm"])
.output()
.unwrap()
})
.await
.unwrap();
assert!(
!output.status.success(),
"expected non-zero exit; stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("pagination"),
"expected pagination error in stderr, got: {stderr}"
);
}
struct ArchiveRenameBody {
expected_id: &'static str,
expected_content: &'static str,
expected_tags: serde_json::Value,
original_name: &'static str,
}
impl wiremock::Match for ArchiveRenameBody {
fn matches(&self, request: &wiremock::Request) -> bool {
let body: serde_json::Value = match serde_json::from_slice(&request.body) {
Ok(v) => v,
Err(_) => return false,
};
let Some(obj) = body.as_object() else {
return false;
};
if obj.contains_key("state") {
return false;
}
let id_ok = obj.get("content_block_id").and_then(|v| v.as_str()) == Some(self.expected_id);
let content_ok = obj.get("content").and_then(|v| v.as_str()) == Some(self.expected_content);
let tags_ok = obj.get("tags") == Some(&self.expected_tags);
let name_ok = obj.get("name").and_then(|v| v.as_str()).is_some_and(|n| {
let Some(rest) = n.strip_prefix("[ARCHIVED-") else {
return false;
};
let Some((date, tail)) = rest.split_once("] ") else {
return false;
};
tail == self.original_name && looks_like_iso_date(date)
});
id_ok && content_ok && tags_ok && name_ok
}
}
fn looks_like_iso_date(s: &str) -> bool {
let bytes = s.as_bytes();
bytes.len() == 10
&& bytes[4] == b'-'
&& bytes[7] == b'-'
&& bytes[..4].iter().all(|b| b.is_ascii_digit())
&& bytes[5..7].iter().all(|b| b.is_ascii_digit())
&& bytes[8..].iter().all(|b| b.is_ascii_digit())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn content_block_archive_orphans_renames_via_update() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/content_blocks/list"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"content_blocks": [{"content_block_id": "id-orphan", "name": "legacy"}]
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/content_blocks/info"))
.and(query_param("content_block_id", "id-orphan"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"name": "legacy",
"content": "preserved body\n",
"tags": ["pr"]
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/content_blocks/update"))
.and(ArchiveRenameBody {
expected_id: "id-orphan",
expected_content: "preserved body\n",
expected_tags: json!(["pr"]),
original_name: "legacy",
})
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"message": "success"})))
.expect(1)
.mount(&server)
.await;
let tmp = tempfile::tempdir().unwrap();
let config_path = write_config(tmp.path(), &server.uri());
tokio::task::spawn_blocking(move || {
Command::cargo_bin("braze-sync")
.unwrap()
.env("BRAZE_API_KEY", "test-key")
.args(["--config", config_path.to_str().unwrap()])
.args([
"apply",
"--resource",
"content_block",
"--confirm",
"--archive-orphans",
])
.assert()
.success();
})
.await
.unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn content_block_archive_orphans_skips_already_archived_blocks() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/content_blocks/list"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"content_blocks": [
{"content_block_id": "id-old", "name": "[ARCHIVED-2024-01-01] ancient"}
]
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/content_blocks/info"))
.respond_with(ResponseTemplate::new(500))
.expect(0)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(500))
.expect(0)
.mount(&server)
.await;
let tmp = tempfile::tempdir().unwrap();
let config_path = write_config(tmp.path(), &server.uri());
tokio::task::spawn_blocking(move || {
Command::cargo_bin("braze-sync")
.unwrap()
.env("BRAZE_API_KEY", "test-key")
.args(["--config", config_path.to_str().unwrap()])
.args([
"apply",
"--resource",
"content_block",
"--confirm",
"--archive-orphans",
])
.assert()
.success();
})
.await
.unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn email_template_dry_run_makes_no_write_calls() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/templates/email/list"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"templates": []})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/templates/email/create"))
.respond_with(ResponseTemplate::new(500))
.expect(0)
.mount(&server)
.await;
let tmp = tempfile::tempdir().unwrap();
let config_path = write_config(tmp.path(), &server.uri());
write_local_email_template(tmp.path(), "fresh", "Welcome", "<p>Hi</p>", "Hi");
tokio::task::spawn_blocking(move || {
Command::cargo_bin("braze-sync")
.unwrap()
.env("BRAZE_API_KEY", "test-key")
.args(["--config", config_path.to_str().unwrap()])
.args(["apply", "--resource", "email_template"])
.assert()
.success();
})
.await
.unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn email_template_confirm_create_posts_to_create_endpoint() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/templates/email/list"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"templates": []})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/templates/email/create"))
.and(body_json(json!({
"template_name": "fresh",
"subject": "Welcome",
"body": "<p>Hi</p>",
"plaintext_body": "Hi",
"tags": []
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"email_template_id": "new-id"
})))
.expect(1)
.mount(&server)
.await;
let tmp = tempfile::tempdir().unwrap();
let config_path = write_config(tmp.path(), &server.uri());
write_local_email_template(tmp.path(), "fresh", "Welcome", "<p>Hi</p>", "Hi");
tokio::task::spawn_blocking(move || {
Command::cargo_bin("braze-sync")
.unwrap()
.env("BRAZE_API_KEY", "test-key")
.args(["--config", config_path.to_str().unwrap()])
.args(["apply", "--resource", "email_template", "--confirm"])
.assert()
.success();
})
.await
.unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn email_template_confirm_update_posts_to_update_endpoint() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/templates/email/list"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"templates": [{"email_template_id": "id-w", "template_name": "welcome"}]
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/templates/email/info"))
.and(query_param("email_template_id", "id-w"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"template_name": "welcome",
"subject": "Old subject",
"body": "<p>Old</p>",
"plaintext_body": "Old",
"tags": [],
"message": "success"
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/templates/email/update"))
.and(body_json(json!({
"email_template_id": "id-w",
"template_name": "welcome",
"subject": "New subject",
"body": "<p>New</p>",
"plaintext_body": "New",
"tags": []
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"message": "success"})))
.expect(1)
.mount(&server)
.await;
let tmp = tempfile::tempdir().unwrap();
let config_path = write_config(tmp.path(), &server.uri());
write_local_email_template(tmp.path(), "welcome", "New subject", "<p>New</p>", "New");
tokio::task::spawn_blocking(move || {
Command::cargo_bin("braze-sync")
.unwrap()
.env("BRAZE_API_KEY", "test-key")
.args(["--config", config_path.to_str().unwrap()])
.args(["apply", "--resource", "email_template", "--confirm"])
.assert()
.success();
})
.await
.unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn email_template_archive_orphans_renames_via_update() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/templates/email/list"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"templates": [{"email_template_id": "id-old", "template_name": "old_promo"}]
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/templates/email/info"))
.and(query_param("email_template_id", "id-old"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"template_name": "old_promo",
"subject": "Old",
"body": "<p>Old</p>",
"plaintext_body": "Old",
"tags": [],
"message": "success"
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/templates/email/update"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"message": "success"})))
.expect(1)
.mount(&server)
.await;
let tmp = tempfile::tempdir().unwrap();
let config_path = write_config(tmp.path(), &server.uri());
tokio::task::spawn_blocking(move || {
Command::cargo_bin("braze-sync")
.unwrap()
.env("BRAZE_API_KEY", "test-key")
.args(["--config", config_path.to_str().unwrap()])
.args([
"apply",
"--resource",
"email_template",
"--confirm",
"--archive-orphans",
])
.assert()
.success();
})
.await
.unwrap();
}