braze-sync 0.2.1

GitOps CLI for managing Braze configuration as code
Documentation
//! Integration tests for `braze-sync export` (Catalog Schema).
//!
//! Each test stands up a wiremock server, writes a temporary
//! braze-sync.config.yaml that points the api_endpoint at the mock,
//! invokes the real binary via assert_cmd, and asserts on the resulting
//! filesystem state and exit code.
//!
//! Tests use `flavor = "multi_thread"` so wiremock can serve HTTP
//! requests on a worker thread while the test thread is parked in
//! `spawn_blocking` waiting on the subprocess. Single-threaded
//! `#[tokio::test]` would deadlock because the blocking subprocess wait
//! would hold the only worker.

mod common;

use assert_cmd::Command;
use common::write_config;
use serde_json::json;
use std::fs;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn export_catalog_schemas_writes_files_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",
                    "description": "Cardiology catalog",
                    "fields": [
                        {"name": "id", "type": "string"},
                        {"name": "score", "type": "number"}
                    ]
                },
                {
                    "name": "dermatology",
                    "fields": [
                        {"name": "id", "type": "string"}
                    ]
                }
            ]
        })))
        .mount(&server)
        .await;

    let tmp = tempfile::tempdir().unwrap();
    let config_path = write_config(tmp.path(), &server.uri());
    let tmp_path = tmp.path().to_path_buf();

    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(["export", "--resource", "catalog_schema"])
            .assert()
            .success();
    })
    .await
    .unwrap();

    let cardiology = tmp_path.join("catalogs/cardiology/schema.yaml");
    let dermatology = tmp_path.join("catalogs/dermatology/schema.yaml");
    assert!(cardiology.exists(), "cardiology schema should exist");
    assert!(dermatology.exists(), "dermatology schema should exist");

    let content = fs::read_to_string(&cardiology).unwrap();
    assert!(content.contains("name: cardiology"));
    assert!(content.contains("- name: id"));
    assert!(content.contains("- name: score"));
    assert!(content.starts_with("# Generated by braze-sync."));
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn export_with_name_filter_uses_get_endpoint() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/catalogs/cardiology"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
            "catalogs": [
                {"name": "cardiology", "fields": [{"name": "id", "type": "string"}]}
            ]
        })))
        .mount(&server)
        .await;

    let tmp = tempfile::tempdir().unwrap();
    let config_path = write_config(tmp.path(), &server.uri());
    let tmp_path = tmp.path().to_path_buf();

    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([
                "export",
                "--resource",
                "catalog_schema",
                "--name",
                "cardiology",
            ])
            .assert()
            .success();
    })
    .await
    .unwrap();

    assert!(
        tmp_path.join("catalogs/cardiology/schema.yaml").exists(),
        "cardiology schema should exist after --name export"
    );
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unauthorized_braze_response_yields_exit_code_4() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/catalogs"))
        .respond_with(ResponseTemplate::new(401).set_body_string("invalid api key"))
        .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", "wrong-key")
            .args(["--config", config_path.to_str().unwrap()])
            .args(["export", "--resource", "catalog_schema"])
            .assert()
            .failure()
            .code(4);
    })
    .await
    .unwrap();
}

#[test]
fn missing_config_file_yields_exit_code_3() {
    Command::cargo_bin("braze-sync")
        .unwrap()
        .env("BRAZE_API_KEY", "anything")
        .args(["--config", "/nonexistent/braze-sync.config.yaml"])
        .args(["export"])
        .assert()
        .failure()
        .code(3);
}

#[test]
fn invalid_args_name_without_resource_yields_exit_code_3() {
    // clap rejects --name without --resource at parse time. We expect
    // exit 3 (config / argument error).
    Command::cargo_bin("braze-sync")
        .unwrap()
        .args(["export", "--name", "x"])
        .assert()
        .failure()
        .code(3);
}

#[test]
fn help_flag_exits_zero() {
    Command::cargo_bin("braze-sync")
        .unwrap()
        .arg("--help")
        .assert()
        .success();
}

// =====================================================================
// Content Block (v0.2.0)
// =====================================================================

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn export_content_blocks_writes_liquid_files() {
    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"},
                {"content_block_id": "id-header", "name": "shared_header"}
            ],
            "message": "success"
        })))
        .mount(&server)
        .await;
    Mock::given(method("GET"))
        .and(path("/content_blocks/info"))
        .and(wiremock::matchers::query_param(
            "content_block_id",
            "id-promo",
        ))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
            "content_block_id": "id-promo",
            "name": "promo",
            "description": "Promo banner",
            "content": "Hello {{ user.${first_name} }}",
            "tags": ["pr"],
            "message": "success"
        })))
        .mount(&server)
        .await;
    Mock::given(method("GET"))
        .and(path("/content_blocks/info"))
        .and(wiremock::matchers::query_param(
            "content_block_id",
            "id-header",
        ))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
            "content_block_id": "id-header",
            "name": "shared_header",
            "content": "<header>shared</header>",
            "tags": [],
            "message": "success"
        })))
        .mount(&server)
        .await;

    let tmp = tempfile::tempdir().unwrap();
    let config_path = write_config(tmp.path(), &server.uri());
    let tmp_path = tmp.path().to_path_buf();

    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(["export", "--resource", "content_block"])
            .assert()
            .success();
    })
    .await
    .unwrap();

    let promo = tmp_path.join("content_blocks/promo.liquid");
    let header = tmp_path.join("content_blocks/shared_header.liquid");
    assert!(promo.exists(), "promo.liquid should exist");
    assert!(header.exists(), "shared_header.liquid should exist");

    let promo_text = fs::read_to_string(&promo).unwrap();
    assert!(promo_text.starts_with("---\n"));
    assert!(promo_text.contains("name: promo"));
    assert!(promo_text.contains("description: Promo banner"));
    assert!(promo_text.contains("Hello {{ user.${first_name} }}"));
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn export_content_block_with_name_filter_only_fetches_matching_info() {
    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"},
                {"content_block_id": "id-header", "name": "shared_header"}
            ]
        })))
        .mount(&server)
        .await;
    // Only the matching info call should fire; the non-matching one
    // would hit this 500 mock and fail the test.
    Mock::given(method("GET"))
        .and(path("/content_blocks/info"))
        .and(wiremock::matchers::query_param(
            "content_block_id",
            "id-header",
        ))
        .respond_with(ResponseTemplate::new(500))
        .expect(0)
        .mount(&server)
        .await;
    Mock::given(method("GET"))
        .and(path("/content_blocks/info"))
        .and(wiremock::matchers::query_param(
            "content_block_id",
            "id-promo",
        ))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
            "name": "promo",
            "content": "x",
            "tags": []
        })))
        .mount(&server)
        .await;

    let tmp = tempfile::tempdir().unwrap();
    let config_path = write_config(tmp.path(), &server.uri());
    let tmp_path = tmp.path().to_path_buf();

    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(["export", "--resource", "content_block", "--name", "promo"])
            .assert()
            .success();
    })
    .await
    .unwrap();

    assert!(tmp_path.join("content_blocks/promo.liquid").exists());
    assert!(!tmp_path
        .join("content_blocks/shared_header.liquid")
        .exists());
}