captchaforge 0.2.39

Captcha detection and solving for Firefox and BiDi-driven browsers. Detection, vendor solver scaffolding, trusted cross-origin click delivery into nested OOPIFs, and stealth personas are implemented and tested; broad live-vendor solve rates are not yet benchmarked.
Documentation
//! Unit tests for [`super`] (config defaults, TOML load/merge, and CLI override precedence).

use super::*;

#[test]
fn empty_toml_round_trips_to_defaults() {
    let c = Config::from_toml_str("").unwrap();
    assert!(c.per_solver_timeout_ms.is_none());
    assert!(c.cache.ttl_seconds.is_none());
    assert!(c.vlm.endpoint.is_none());
    assert!(c.third_party.service.is_none());
}

#[test]
fn typo_in_top_level_key_is_a_parse_error() {
    // deny_unknown_fields catches typos like `per_sovler_timeout_ms`
    // so users don't lose hours wondering why their override is
    // ignored.
    let r = Config::from_toml_str("per_sovler_timeout_ms = 100");
    assert!(r.is_err(), "deny_unknown_fields should reject typos");
}

#[test]
fn full_toml_parses_and_materialises() {
    let toml = r#"
per_solver_timeout_ms = 60000
screenshot_on_failure = false

[cache]
ttl_seconds = 120

[vlm]
endpoint = "http://gpu:11434"
model    = "qwen3-vl:7b"

[third_party]
service  = "cap_monster"
api_key  = "xyz"
poll_interval_ms = 7000
max_polls = 12

[solve]
checkbox_max_attempts = 25
vlm_http_timeout_ms   = 30000
"#;
    let c = Config::from_toml_str(toml).unwrap();
    assert_eq!(c.per_solver_timeout_ms, Some(60000));
    assert_eq!(c.screenshot_on_failure, Some(false));
    assert_eq!(c.cache.ttl_seconds, Some(120));
    assert_eq!(c.vlm.endpoint.as_deref(), Some("http://gpu:11434"));
    assert_eq!(c.vlm.model.as_deref(), Some("qwen3-vl:7b"));
    assert_eq!(c.third_party.service.as_deref(), Some("cap_monster"));
    assert_eq!(c.third_party.api_key.as_deref(), Some("xyz"));
    assert_eq!(c.solve.checkbox_max_attempts, Some(25));
    assert_eq!(c.solve.vlm_http_timeout_ms, Some(30000));

    let cc = c.chain_config();
    assert_eq!(cc.per_solver_timeout_ms, 60000);
    assert!(!cc.screenshot_on_failure);

    let sc = c.solve_config();
    assert_eq!(sc.checkbox_max_attempts, 25);
    assert_eq!(sc.vlm_http_timeout_ms, 30000);
    // Untouched fields keep defaults.
    assert_eq!(
        sc.token_max_attempts,
        SolveConfig::default().token_max_attempts
    );
}

#[test]
fn build_vlm_solver_applies_endpoint_and_model() {
    let c = Config::from_toml_str(
        r#"
[vlm]
endpoint = "http://x:11434"
model    = "m:latest"
"#,
    )
    .unwrap();
    let s = c.build_vlm_solver();
    assert_eq!(s.endpoint, "http://x:11434");
    assert_eq!(s.model, "m:latest");
}

#[test]
fn build_third_party_solver_is_none_when_section_empty() {
    let c = Config::default();
    assert!(c.build_third_party_solver().is_none());
}

#[test]
fn build_third_party_solver_dispatches_service_variants() {
    for (toml, expected_base) in [
        (
            r#"[third_party]
service = "two_captcha"
"#,
            "https://2captcha.com",
        ),
        (
            r#"[third_party]
service = "cap_monster"
"#,
            "https://api.capmonster.cloud",
        ),
        (
            r#"[third_party]
service = "cap_solver"
"#,
            "https://api.capsolver.com",
        ),
        (
            r#"[third_party]
service = "custom"
base_url = "https://my.gw"
"#,
            "https://my.gw",
        ),
    ] {
        let c = Config::from_toml_str(toml).unwrap();
        let s = c
            .build_third_party_solver()
            .expect("config has third_party set; should build a solver");
        assert_eq!(s.service.base_url(), expected_base);
    }
}

#[test]
fn build_third_party_solver_applies_api_key_and_polling() {
    let c = Config::from_toml_str(
        r#"
[third_party]
service = "two_captcha"
api_key = "k123"
poll_interval_ms = 9000
max_polls = 7
"#,
    )
    .unwrap();
    let s = c.build_third_party_solver().unwrap();
    assert_eq!(s.api_key.as_deref(), Some("k123"));
    assert_eq!(s.poll_interval_ms, 9000);
    assert_eq!(s.max_polls, 7);
    assert!(s.has_api_key());
}

#[test]
fn build_third_party_solver_skips_empty_api_key() {
    let c = Config::from_toml_str(
        r#"
[third_party]
service = "two_captcha"
api_key = ""
"#,
    )
    .unwrap();
    let s = c.build_third_party_solver().unwrap();
    // Empty api_key in the TOML doesn't override the env-var
    // fallback path (which we mock as "no key set" here).
    assert_eq!(s.api_key.as_deref().unwrap_or(""), "");
}

#[test]
fn build_token_cache_honours_configured_ttl() {
    let c = Config::from_toml_str(
        r#"[cache]
ttl_seconds = 5
"#,
    )
    .unwrap();
    let cache = c.build_token_cache();
    assert_eq!(cache.ttl(), std::time::Duration::from_secs(5));
}

#[test]
fn build_token_cache_defaults_to_60_seconds() {
    let c = Config::default();
    let cache = c.build_token_cache();
    assert_eq!(cache.ttl(), std::time::Duration::from_secs(60));
}

#[test]
fn search_paths_includes_explicit_env_first() {
    let paths = Config::search_paths_with_env(|name| match name {
        "CAPTCHAFORGE_CONFIG" => Some("/tmp/explicit.toml".into()),
        "HOME" => Some("/home/user".into()),
        _ => None,
    });
    assert!(paths
        .first()
        .unwrap()
        .to_string_lossy()
        .contains("explicit"));
}

#[test]
fn search_paths_skips_empty_explicit_env() {
    let paths = Config::search_paths_with_env(|name| match name {
        "CAPTCHAFORGE_CONFIG" => Some(String::new()),
        "HOME" => Some("/home/user".into()),
        _ => None,
    });
    // First path should be the project-local file, not the empty env.
    assert_eq!(paths.first().unwrap(), &PathBuf::from(".captchaforge.toml"));
}

#[test]
fn search_paths_appends_xdg_config_home() {
    let paths = Config::search_paths_with_env(|name| match name {
        "XDG_CONFIG_HOME" => Some("/x".into()),
        _ => None,
    });
    let last = paths.last().unwrap();
    assert!(
        last.to_string_lossy()
            .contains("/x/captchaforge/config.toml"),
        "expected XDG path; got {:?}",
        last
    );
}

#[test]
fn search_paths_falls_back_to_home_dotconfig() {
    let paths = Config::search_paths_with_env(|name| match name {
        "HOME" => Some("/home/u".into()),
        _ => None,
    });
    let last = paths.last().unwrap();
    assert!(
        last.to_string_lossy()
            .contains("/home/u/.config/captchaforge/config.toml"),
        "expected HOME-based fallback; got {:?}",
        last
    );
}

#[test]
fn load_from_path_round_trips_disk_file() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("captchaforge.toml");
    std::fs::write(&path, "per_solver_timeout_ms = 12345\n").unwrap();
    let c = Config::load_from_path(&path).unwrap();
    assert_eq!(c.per_solver_timeout_ms, Some(12345));
}

#[test]
fn load_from_path_errors_on_missing_file() {
    assert!(Config::load_from_path("/no/such/captchaforge.toml").is_err());
}

#[test]
fn build_chain_default_config_has_all_default_chain_solvers_plus_config_extensions() {
    // Anchor: build_chain must be a strict SUPERSET of
    // default_chain so `auto_solve` / `solve_url` never lack a
    // vendor-specific solver that the documented default chain
    // already provides.
    let default = crate::solver::CaptchaSolverChain::default_chain();
    let configured = Config::default().build_chain().unwrap();
    let default_names: std::collections::HashSet<&str> =
        default.solver_names().into_iter().collect();
    let configured_names: std::collections::HashSet<&str> =
        configured.solver_names().into_iter().collect();
    for name in &default_names {
        assert!(
            configured_names.contains(*name),
            "Config::build_chain dropped solver {name} that default_chain has. \
                 production one-call flow would silently lack this vendor's strategy"
        );
    }
    // And at least one config-driven extension (MultiStep / Ocr / Vlm overlay).
    assert!(
        configured.solvers.len() >= default.solvers.len(),
        "configured chain ({}) shorter than default ({})",
        configured.solvers.len(),
        default.solvers.len()
    );
}

#[test]
fn build_chain_honors_per_solver_timeout_from_toml() {
    let c = Config::from_toml_str("per_solver_timeout_ms = 7777").unwrap();
    let chain = c.build_chain().unwrap();
    assert_eq!(chain.config.per_solver_timeout_ms, 7777);
}

#[test]
fn warn_on_inline_secrets_is_method_callable_idempotent() {
    // The warning is observable only via tracing; here we just
    // assert the method exists, accepts a path, and doesn't panic
    // on either an empty or populated api_key.
    let empty = Config::default();
    empty.warn_on_inline_secrets(std::path::Path::new("test.toml"));

    let with_key = Config::from_toml_str(
        r#"[third_party]
api_key = "secret"
"#,
    )
    .unwrap();
    with_key.warn_on_inline_secrets(std::path::Path::new("test.toml"));
}

#[test]
fn build_chain_uses_configured_third_party_when_section_present() {
    let c = Config::from_toml_str(
        r#"
[third_party]
service = "cap_solver"
api_key = "k"
"#,
    )
    .unwrap();
    let chain = c.build_chain().unwrap();
    // The chain is default_chain (17 solvers) + MultiStep + Ocr +
    // configured Vlm + configured ThirdParty = 21. Anchoring on
    // ≥17 future-proofs against adding more vendor-specific
    // solvers without rewriting this test on every commit.
    assert!(
        chain.solvers.len() >= 17,
        "configured chain shrunk below default_chain size: {}",
        chain.solvers.len()
    );
    // Last solver is the configured third-party (cap_solver here).
    assert_eq!(
        chain.solvers[chain.solvers.len() - 1].name(),
        "ThirdPartyCaptchaSolver"
    );
}