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() {
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);
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();
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,
});
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() {
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"
);
}
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() {
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();
assert!(
chain.solvers.len() >= 17,
"configured chain shrunk below default_chain size: {}",
chain.solvers.len()
);
assert_eq!(
chain.solvers[chain.solvers.len() - 1].name(),
"ThirdPartyCaptchaSolver"
);
}