earl 0.5.2

AI-safe CLI for AI agents
use std::collections::BTreeMap;

use earl::protocol::extract::extract_result;
use earl::protocol::transport::{ResolvedTransport, resolve_transport};
use earl::template::schema::{
    RedirectTemplate, ResultDecode, ResultExtract, RetryTemplate, TlsTemplate, TransportTemplate,
};
use earl_core::decode::{DecodedBody, decode_response};
use serde_json::json;

#[test]
fn auto_decode_json_content_type_returns_json_body() {
    let json_bytes = br#"{"ok":true}"#;
    let decoded =
        decode_response(ResultDecode::Auto, Some("application/json"), json_bytes).unwrap();
    let DecodedBody::Json(value) = decoded else {
        panic!("expected JSON")
    };
    assert_eq!(value["ok"], json!(true));
}

#[test]
fn auto_decode_text_content_type_returns_text_body() {
    let text_bytes = b"hello";
    let decoded = decode_response(ResultDecode::Auto, Some("text/plain"), text_bytes).unwrap();
    let DecodedBody::Text(value) = decoded else {
        panic!("expected text")
    };
    assert_eq!(value, "hello");
}

#[test]
fn binary_body_base64_encodes_when_converted_to_json() {
    let bytes = vec![1_u8, 2, 3, 4];
    let decoded = decode_response(
        ResultDecode::Binary,
        Some("application/octet-stream"),
        &bytes,
    )
    .unwrap();
    let as_json = decoded.to_json_value();
    assert_eq!(as_json, json!("AQIDBA=="));
}

#[test]
fn json_pointer_extracts_nested_value() {
    let decoded_json = DecodedBody::Json(json!({"data": {"id": 42}}));
    let out = extract_result(
        Some(&ResultExtract::JsonPointer {
            json_pointer: "/data/id".to_string(),
        }),
        &decoded_json,
    )
    .unwrap();
    assert_eq!(out, json!(42));
}

#[test]
fn regex_extracts_capture_group_from_text() {
    let decoded_text = DecodedBody::Text("id=abc-123".to_string());
    let out = extract_result(
        Some(&ResultExtract::Regex {
            regex: "id=([a-z0-9-]+)".to_string(),
        }),
        &decoded_text,
    )
    .unwrap();
    assert_eq!(out, json!("abc-123"));
}

#[test]
fn css_selector_extracts_all_matching_elements() {
    let decoded_html =
        DecodedBody::Html("<html><body><h1>Hello</h1><h1>World</h1></body></html>".to_string());
    let out = extract_result(
        Some(&ResultExtract::CssSelector {
            css_selector: "h1".to_string(),
        }),
        &decoded_html,
    )
    .unwrap();
    assert_eq!(out, json!(["Hello", "World"]));
}

#[test]
fn xpath_extracts_text_nodes() {
    let decoded_xml = DecodedBody::Xml("<root><item>A</item><item>B</item></root>".to_string());
    let out = extract_result(
        Some(&ResultExtract::XPath {
            xpath: "//item/text()".to_string(),
        }),
        &decoded_xml,
    )
    .unwrap();
    assert_eq!(out, json!(["A", "B"]));
}

#[test]
fn json_pointer_on_missing_key_returns_error() {
    let decoded_json = DecodedBody::Json(json!({"a": 1}));
    extract_result(
        Some(&ResultExtract::JsonPointer {
            json_pointer: "/missing".to_string(),
        }),
        &decoded_json,
    )
    .unwrap_err();
}

#[test]
fn default_transport_retry_max_attempts_is_one() {
    let defaults = resolve_transport(None, &BTreeMap::new()).unwrap();
    assert_eq!(defaults.retry_max_attempts, 1);
}

#[test]
fn default_transport_max_redirect_hops_is_five() {
    let defaults = resolve_transport(None, &BTreeMap::new()).unwrap();
    assert_eq!(defaults.max_redirect_hops, 5);
}

#[test]
fn default_transport_compression_is_enabled() {
    let defaults = resolve_transport(None, &BTreeMap::new()).unwrap();
    assert!(defaults.compression);
}

#[test]
fn default_transport_max_response_bytes_is_eight_mib() {
    let defaults = resolve_transport(None, &BTreeMap::new()).unwrap();
    assert_eq!(defaults.max_response_bytes, 8 * 1024 * 1024);
}

fn resolved_override_transport() -> ResolvedTransport {
    let override_input = TransportTemplate {
        timeout_ms: Some(2_000),
        max_response_bytes: Some(16 * 1024),
        redirects: Some(RedirectTemplate {
            follow: false,
            max_hops: 2,
        }),
        retry: Some(RetryTemplate {
            max_attempts: 0,
            backoff_ms: 0,
            retry_on_status: vec![429, 500],
        }),
        compression: Some(true),
        tls: Some(TlsTemplate {
            min_version: Some("1.2".to_string()),
        }),
        proxy_profile: Some("corp".to_string()),
    };

    let proxy_profiles = BTreeMap::from([(
        "corp".to_string(),
        earl::config::ProxyProfile {
            url: "http://127.0.0.1:8888".to_string(),
        },
    )]);

    resolve_transport(Some(&override_input), &proxy_profiles).unwrap()
}

#[test]
fn transport_timeout_resolved_from_template() {
    assert_eq!(resolved_override_transport().timeout.as_millis(), 2_000);
}

#[test]
fn transport_follow_redirects_disabled_when_template_sets_follow_false() {
    assert!(!resolved_override_transport().follow_redirects);
}

#[test]
fn transport_max_redirect_hops_resolved_from_template() {
    assert_eq!(resolved_override_transport().max_redirect_hops, 2);
}

#[test]
fn transport_retry_max_attempts_clamped_to_minimum() {
    assert_eq!(resolved_override_transport().retry_max_attempts, 1);
}

#[test]
fn transport_retry_on_status_resolved_from_template() {
    assert_eq!(
        resolved_override_transport().retry_on_status,
        vec![429, 500]
    );
}

#[test]
fn transport_compression_resolved_from_template() {
    assert!(resolved_override_transport().compression);
}

#[test]
fn transport_max_response_bytes_resolved_from_template() {
    assert_eq!(resolved_override_transport().max_response_bytes, 16 * 1024);
}

#[test]
fn transport_proxy_url_resolved_from_profile() {
    assert_eq!(
        resolved_override_transport().proxy_url.as_deref(),
        Some("http://127.0.0.1:8888")
    );
}

#[test]
fn transport_tls_min_version_resolved_from_template() {
    assert_eq!(
        resolved_override_transport().tls_min_version,
        Some(reqwest::tls::Version::TLS_1_2)
    );
}