mod common;
use assert_cmd::Command;
use common::{
write_config, write_local_content_block, write_local_custom_attribute_registry,
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 confirm_with_allow_destructive_deletes_entire_catalog_in_one_call() {
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"))
.respond_with(ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
Mock::given(method("DELETE"))
.and(path("/catalogs/cardiology/fields/legacy"))
.respond_with(ResponseTemplate::new(204))
.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",
"catalog_schema",
"--confirm",
"--allow-destructive",
])
.assert()
.success();
})
.await
.unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn confirm_without_allow_destructive_blocks_full_catalog_delete() {
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("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());
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 confirm_with_new_catalog_posts_create_and_skips_per_field_post() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/catalogs"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"catalogs": []
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/catalogs"))
.and(body_json(json!({
"catalogs": [{
"name": "cardiology",
"fields": [
{"name": "id", "type": "string"},
{"name": "severity", "type": "number"}
]
}]
})))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({"message": "ok"})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/catalogs/cardiology/fields"))
.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", "--confirm"])
.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();
}
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();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn apply_custom_attribute_deprecation_toggle_with_confirm() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/custom_attributes"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"attributes": [
{
"name": "legacy_field",
"data_type": "string",
"status": "Active"
}
]
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/custom_attributes/blocklist"))
.and(body_json(json!({
"custom_attribute_names": ["legacy_field"],
"blocklisted": true
})))
.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_custom_attribute_registry(
tmp.path(),
"attributes:\n - name: legacy_field\n type: string\n deprecated: true\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", "custom_attribute", "--confirm"])
.assert()
.success();
})
.await
.unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn apply_custom_attribute_dry_run_makes_no_write_call() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/custom_attributes"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"attributes": [
{
"name": "legacy_field",
"data_type": "string",
"status": "Active"
}
]
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/custom_attributes/blocklist"))
.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_custom_attribute_registry(
tmp.path(),
"attributes:\n - name: legacy_field\n type: string\n deprecated: true\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", "custom_attribute"]) .assert()
.success();
})
.await
.unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn apply_custom_attribute_present_in_git_only_is_informational_no_op() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/custom_attributes"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"attributes": []
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/custom_attributes/blocklist"))
.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_custom_attribute_registry(
tmp.path(),
"attributes:\n - name: typo_attr\n type: string\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", "custom_attribute", "--confirm"])
.output()
.unwrap()
})
.await
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("in Git registry but not in Braze"),
"expected PresentInGitOnly warning in plan output; stdout: {stdout}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn apply_mixed_registry_only_ca_does_not_block_content_block_create() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/catalogs"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"catalogs": []
})))
.mount(&server)
.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("GET"))
.and(path("/templates/email/list"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"count": 0,
"templates": []
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/custom_attributes"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"attributes": []
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/content_blocks/create"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"content_block_id": "new-id-1",
"message": "success"
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/custom_attributes/blocklist"))
.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");
write_local_custom_attribute_registry(
tmp.path(),
"attributes:\n - name: typo_attr\n type: string\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", "--confirm"])
.output()
.unwrap()
})
.await
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn apply_custom_attribute_metadata_only_is_informational_no_op() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/custom_attributes"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"attributes": [
{
"name": "drift",
"data_type": "string",
"description": "remote desc",
"status": "Active"
}
]
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/custom_attributes/blocklist"))
.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_custom_attribute_registry(
tmp.path(),
"attributes:\n - name: drift\n type: string\n description: local desc\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", "custom_attribute", "--confirm"])
.output()
.unwrap()
})
.await
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("No actionable changes to apply"),
"expected informational-drift message; stderr: {stderr}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn apply_custom_attribute_batches_both_directions() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/custom_attributes"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"attributes": [
{
"name": "to_deprecate",
"data_type": "string",
"status": "Active"
},
{
"name": "to_reactivate",
"data_type": "string",
"status": "Blocklisted"
}
]
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/custom_attributes/blocklist"))
.and(body_json(json!({
"custom_attribute_names": ["to_deprecate"],
"blocklisted": true
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"message": "success"})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/custom_attributes/blocklist"))
.and(body_json(json!({
"custom_attribute_names": ["to_reactivate"],
"blocklisted": false
})))
.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_custom_attribute_registry(
tmp.path(),
"attributes:\n \
- name: to_deprecate\n type: string\n deprecated: true\n \
- name: to_reactivate\n type: string\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", "custom_attribute", "--confirm"])
.output()
.unwrap()
})
.await
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("Applied 2 change(s)"), "stderr: {stderr}");
}
async fn received_create_names(server: &MockServer) -> Vec<String> {
server
.received_requests()
.await
.expect("recording is on by default")
.iter()
.filter(|r| {
r.method == wiremock::http::Method::POST && r.url.path() == "/content_blocks/create"
})
.map(|r| {
let v: serde_json::Value = serde_json::from_slice(&r.body).unwrap();
v.get("name").and_then(|n| n.as_str()).unwrap().to_string()
})
.collect()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn content_block_apply_creates_dependency_target_before_referrer() {
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"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"content_block_id": "new-id",
"message": "success"
})))
.expect(2)
.mount(&server)
.await;
let tmp = tempfile::tempdir().unwrap();
let config_path = write_config(tmp.path(), &server.uri());
write_local_content_block(
tmp.path(),
"a_referrer",
"see {{content_blocks.${b_target} | id: 'cb1'}}\n",
);
write_local_content_block(tmp.path(), "b_target", "leaf body\n");
let dry_run = tokio::task::spawn_blocking({
let cfg = config_path.clone();
move || {
Command::cargo_bin("braze-sync")
.unwrap()
.env("BRAZE_API_KEY", "test-key")
.args(["--config", cfg.to_str().unwrap()])
.args(["apply", "--resource", "content_block"])
.output()
.unwrap()
}
})
.await
.unwrap();
assert!(dry_run.status.success(), "dry-run must succeed");
let dry_stdout = String::from_utf8_lossy(&dry_run.stdout);
let pos_target = dry_stdout
.find("Content Block: b_target")
.expect("plan must list b_target");
let pos_referrer = dry_stdout
.find("Content Block: a_referrer")
.expect("plan must list a_referrer");
assert!(
pos_target < pos_referrer,
"dry-run plan must list b_target before a_referrer; got:\n{dry_stdout}"
);
tokio::task::spawn_blocking({
let cfg = config_path.clone();
move || {
Command::cargo_bin("braze-sync")
.unwrap()
.env("BRAZE_API_KEY", "test-key")
.args(["--config", cfg.to_str().unwrap()])
.args(["apply", "--resource", "content_block", "--confirm"])
.assert()
.success();
}
})
.await
.unwrap();
assert_eq!(
received_create_names(&server).await,
vec!["b_target".to_string(), "a_referrer".to_string()],
"dependency target must be created before referrer"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn content_block_apply_aborts_on_reference_cycle_before_any_write() {
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(), "cycle_a", "{{content_blocks.${cycle_b}}}\n");
write_local_content_block(tmp.path(), "cycle_b", "{{content_blocks.${cycle_a}}}\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");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("dependency cycle"),
"expected cycle error in stderr; got:\n{stderr}"
);
assert!(
stderr.contains("cycle_a") && stderr.contains("cycle_b"),
"cycle error must name both blocks; got:\n{stderr}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn content_block_apply_alphabetical_opt_out_preserves_input_order() {
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"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"content_block_id": "new-id",
"message": "success"
})))
.expect(2)
.mount(&server)
.await;
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("braze-sync.config.yaml");
let yaml = format!(
"version: 1
default_environment: test
environments:
test:
api_endpoint: {uri}
api_key_env: BRAZE_API_KEY
resources:
content_block:
enabled: true
path: content_blocks/
apply_order: alphabetical
",
uri = server.uri()
);
std::fs::write(&config_path, yaml).unwrap();
write_local_content_block(
tmp.path(),
"a_referrer",
"see {{content_blocks.${b_target}}}\n",
);
write_local_content_block(tmp.path(), "b_target", "leaf\n");
let dry_run = tokio::task::spawn_blocking({
let cfg = config_path.clone();
move || {
Command::cargo_bin("braze-sync")
.unwrap()
.env("BRAZE_API_KEY", "test-key")
.args(["--config", cfg.to_str().unwrap()])
.args(["apply", "--resource", "content_block"])
.output()
.unwrap()
}
})
.await
.unwrap();
assert!(dry_run.status.success(), "dry-run must succeed");
let dry_stdout = String::from_utf8_lossy(&dry_run.stdout);
let pos_referrer = dry_stdout
.find("Content Block: a_referrer")
.expect("plan must list a_referrer");
let pos_target = dry_stdout
.find("Content Block: b_target")
.expect("plan must list b_target");
assert!(
pos_referrer < pos_target,
"dry-run plan under alphabetical mode must keep a_referrer before b_target; got:\n{dry_stdout}"
);
tokio::task::spawn_blocking({
let cfg = config_path.clone();
move || {
Command::cargo_bin("braze-sync")
.unwrap()
.env("BRAZE_API_KEY", "test-key")
.args(["--config", cfg.to_str().unwrap()])
.args(["apply", "--resource", "content_block", "--confirm"])
.assert()
.success();
}
})
.await
.unwrap();
assert_eq!(
received_create_names(&server).await,
vec!["a_referrer".to_string(), "b_target".to_string()],
"alphabetical opt-out must preserve input order"
);
}