mechanics-core 0.2.2

mechanics automation framework (core)
Documentation
use super::super::*;
use serde_json::json;

#[test]
fn endpoint_deserializes_from_snake_case_body_types() {
    let endpoint: HttpEndpoint = serde_json::from_value(json!({
        "method": "post",
        "url_template": "https://example.com/{id}",
        "url_param_specs": { "id": {} },
        "request_body_type": "bytes",
        "response_body_type": "utf8"
    }))
    .expect("snake_case endpoint config should deserialize");

    let mut options = EndpointCallOptions::default();
    options.url_params.insert("id".to_owned(), "1".to_owned());
    let url = endpoint
        .build_url(&options)
        .expect("deserialized endpoint should build URL");
    assert_eq!(url.as_str(), "https://example.com/1");
}

#[test]
fn mechanics_config_new_rejects_invalid_endpoint_configuration() {
    let endpoint = HttpEndpoint::new(HttpMethod::Get, "https://example.com/{id}", HashMap::new())
        .with_url_param_specs(HashMap::from([(
            "other".to_owned(),
            UrlParamSpec::default(),
        )]));

    let mut endpoints = HashMap::new();
    endpoints.insert("bad".to_owned(), endpoint);

    let err = MechanicsConfig::new(endpoints).expect_err("config should fail fast");
    assert!(matches!(err, crate::MechanicsError::RuntimePool(_)));
    assert!(
        err.msg()
            .contains("missing url_param_specs entry for slot `id`")
    );
}

#[test]
fn mechanics_config_deserialize_rejects_invalid_endpoint_configuration() {
    let err = serde_json::from_value::<MechanicsConfig>(json!({
        "endpoints": {
            "bad": {
                "method": "get",
                "url_template": "https://example.com/{id}",
                "url_param_specs": { "other": {} }
            }
        }
    }))
    .expect_err("deserialization should fail fast");

    assert!(
        err.to_string()
            .contains("missing url_param_specs entry for slot `id`")
    );
}

#[test]
fn mechanics_config_deserialize_rejects_unknown_top_level_field() {
    let err = serde_json::from_value::<MechanicsConfig>(json!({
        "endpoints": {},
        "unknown": true
    }))
    .expect_err("unknown top-level fields must be rejected");

    assert!(err.to_string().contains("unknown field"));
}

#[test]
fn mechanics_config_rejects_invalid_header_allowlist_name() {
    let endpoint: HttpEndpoint = serde_json::from_value(json!({
        "method": "post",
        "url_template": "https://example.com/{id}",
        "url_param_specs": { "id": {} },
        "overridable_request_headers": ["bad header"]
    }))
    .expect("endpoint itself should deserialize");

    let mut endpoints = HashMap::new();
    endpoints.insert("bad".to_owned(), endpoint);
    let err = MechanicsConfig::new(endpoints).expect_err("config should fail fast");
    assert!(err.msg().contains("invalid header name `bad header`"));
}

#[test]
fn mechanics_config_rejects_case_insensitive_duplicate_endpoint_headers() {
    let endpoint: HttpEndpoint = serde_json::from_value(json!({
        "method": "post",
        "url_template": "https://example.com/{id}",
        "url_param_specs": { "id": {} },
        "headers": {
            "x-dup": "one",
            "X-DUP": "two"
        }
    }))
    .expect("endpoint itself should deserialize");

    let mut endpoints = HashMap::new();
    endpoints.insert("bad".to_owned(), endpoint);
    let err = MechanicsConfig::new(endpoints).expect_err("config should reject duplicate headers");
    assert!(err.msg().contains("duplicate header name"));
}

#[test]
fn mechanics_config_rejects_unknown_endpoint_field() {
    let err = serde_json::from_value::<MechanicsConfig>(json!({
        "endpoints": {
            "bad": {
                "method": "get",
                "url_template": "https://example.com/{id}",
                "url_param_specs": { "id": {} },
                "unknown_field": 123
            }
        }
    }))
    .expect_err("unknown endpoint fields must be rejected");

    assert!(err.to_string().contains("unknown field"));
}

#[test]
fn mechanics_config_rejects_zero_timeout_and_response_max_bytes() {
    for endpoint in [
        json!({
            "method": "get",
            "url_template": "https://example.com/{id}",
            "url_param_specs": { "id": {} },
            "timeout_ms": 0
        }),
        json!({
            "method": "get",
            "url_template": "https://example.com/{id}",
            "url_param_specs": { "id": {} },
            "response_max_bytes": 0
        }),
    ] {
        let err = serde_json::from_value::<MechanicsConfig>(json!({
            "endpoints": { "bad": endpoint }
        }))
        .expect_err("zero values must be rejected");

        assert!(
            err.to_string().contains("must be >= 1"),
            "unexpected error: {err}"
        );
    }
}

#[test]
fn mechanics_config_allows_empty_default_for_optional_query_with_min_bytes() {
    let endpoint: HttpEndpoint = serde_json::from_value(json!({
        "method": "get",
        "url_template": "https://example.com/{id}",
        "url_param_specs": { "id": {} },
        "query_specs": [{
            "type": "slotted",
            "key": "q",
            "slot": "q",
            "mode": "optional",
            "default": "",
            "min_bytes": 1
        }]
    }))
    .expect("endpoint should deserialize");

    let mut endpoints = HashMap::new();
    endpoints.insert("ok".to_owned(), endpoint);
    MechanicsConfig::new(endpoints).expect("empty optional default should be treated as omitted");
}

#[test]
fn mechanics_config_rejects_empty_default_for_optional_allow_empty_with_min_bytes() {
    let endpoint: HttpEndpoint = serde_json::from_value(json!({
        "method": "get",
        "url_template": "https://example.com/{id}",
        "url_param_specs": { "id": {} },
        "query_specs": [{
            "type": "slotted",
            "key": "q",
            "slot": "q",
            "mode": "optional_allow_empty",
            "default": "",
            "min_bytes": 1
        }]
    }))
    .expect("endpoint should deserialize");

    let mut endpoints = HashMap::new();
    endpoints.insert("bad".to_owned(), endpoint);
    let err = MechanicsConfig::new(endpoints)
        .expect_err("empty optional_allow_empty default should violate min_bytes");
    assert!(err.msg().contains("too short"));
}

#[test]
fn endpoint_deserializes_additional_http_methods() {
    for method in ["patch", "head", "options"] {
        let endpoint: HttpEndpoint = serde_json::from_value(json!({
            "method": method,
            "url_template": "https://example.com/{id}",
            "url_param_specs": { "id": {} }
        }))
        .expect("endpoint should deserialize additional method");

        let mut options = EndpointCallOptions::default();
        options.url_params.insert("id".to_owned(), "1".to_owned());
        let url = endpoint
            .build_url(&options)
            .expect("deserialized endpoint should build URL");
        assert_eq!(url.as_str(), "https://example.com/1");
    }
}

#[test]
fn http_method_body_support_matrix_matches_contract() {
    assert!(HttpMethod::Post.supports_request_body());
    assert!(HttpMethod::Put.supports_request_body());
    assert!(HttpMethod::Patch.supports_request_body());
    assert!(!HttpMethod::Get.supports_request_body());
    assert!(!HttpMethod::Delete.supports_request_body());
    assert!(!HttpMethod::Head.supports_request_body());
    assert!(!HttpMethod::Options.supports_request_body());
}

#[test]
fn mechanics_config_composition_helpers_apply_validation_and_overrides() {
    let base = MechanicsConfig::new(HashMap::from([(
        "base".to_owned(),
        HttpEndpoint::new(HttpMethod::Get, "https://example.com/{id}", HashMap::new())
            .with_url_param_specs(HashMap::from([("id".to_owned(), UrlParamSpec::default())])),
    )]))
    .expect("base config should build");

    let over = HttpEndpoint::new(
        HttpMethod::Patch,
        "https://example.com/{id}",
        HashMap::new(),
    )
    .with_url_param_specs(HashMap::from([("id".to_owned(), UrlParamSpec::default())]));
    let cfg = base
        .clone()
        .with_endpoint("base", over.clone())
        .expect("single endpoint override should validate");
    assert_eq!(cfg.endpoints["base"].method, HttpMethod::Patch);

    let cfg = base
        .with_endpoint_overrides(HashMap::from([("extra".to_owned(), over)]))
        .expect("bulk overrides should validate");
    assert!(cfg.endpoints.contains_key("base"));
    assert!(cfg.endpoints.contains_key("extra"));

    let removed = cfg.without_endpoint("extra");
    assert!(!removed.endpoints.contains_key("extra"));
}

#[test]
fn mechanics_config_composition_helpers_reject_invalid_endpoint() {
    let base = MechanicsConfig::new(HashMap::new()).expect("base config should build");
    let invalid = HttpEndpoint::new(HttpMethod::Get, "https://example.com/{id}", HashMap::new())
        .with_url_param_specs(HashMap::from([(
            "other".to_owned(),
            UrlParamSpec::default(),
        )]));

    let err = base
        .with_endpoint("bad", invalid)
        .expect_err("invalid endpoint must be rejected");
    assert!(
        err.msg()
            .contains("missing url_param_specs entry for slot `id`")
    );
}