use super::*;
use crate::solver::SolveMethod;
#[test]
fn solver_chain_add_and_count() {
let mut chain = CaptchaSolverChain::empty();
chain.add_solver(BehavioralCaptchaSolver::new());
chain.add_solver(AudioCaptchaSolver::new());
assert_eq!(chain.solvers.len(), 2);
}
#[test]
fn default_chain_has_twentyone_solvers_in_documented_order() {
let chain = CaptchaSolverChain::default_chain();
assert_eq!(chain.solvers.len(), 21);
assert_eq!(chain.solvers[0].name(), "WaitForTokenSolver");
assert_eq!(chain.solvers[1].name(), "CloudflareInterstitialSolver");
assert_eq!(chain.solvers[2].name(), "AkamaiInterstitialSolver");
assert_eq!(chain.solvers[3].name(), "DataDomeSolver");
assert_eq!(chain.solvers[4].name(), "PerimeterXSolver");
assert_eq!(chain.solvers[5].name(), "GeeTestSolver");
assert_eq!(chain.solvers[6].name(), "AwsWafSolver");
assert_eq!(chain.solvers[7].name(), "ArkoseSolver");
assert_eq!(chain.solvers[8].name(), "ImpervaSolver");
assert_eq!(chain.solvers[9].name(), "KasadaSolver");
assert_eq!(chain.solvers[10].name(), "MathCaptchaSolver");
assert_eq!(chain.solvers[11].name(), "PowCaptchaSolver");
assert_eq!(chain.solvers[12].name(), "SliderCaptchaSolver");
assert_eq!(chain.solvers[13].name(), "TurnstileInteractiveSolver");
assert_eq!(chain.solvers[14].name(), "BehavioralCaptchaSolver");
assert_eq!(chain.solvers[15].name(), "YoloGridSolver");
assert_eq!(chain.solvers[16].name(), "CrnnTextSolver");
assert_eq!(chain.solvers[17].name(), "VlmCaptchaSolver");
assert_eq!(chain.solvers[18].name(), "RecaptchaAudioSolver");
assert_eq!(chain.solvers[19].name(), "AudioCaptchaSolver");
assert_eq!(chain.solvers[20].name(), "ThirdPartyCaptchaSolver");
}
#[test]
fn default_chain_third_party_inert_without_api_key() {
if std::env::var("CAPTCHAFORGE_THIRDPARTY_API_KEY").is_ok() {
return;
}
let chain = CaptchaSolverChain::default_chain();
let ordered = chain.ordered_solvers(
"example.com",
&CaptchaType::CloudflareTurnstile,
&crate::captcha_detect::DetectedCaptcha::Turnstile,
);
for s in ordered {
assert_ne!(
s.name(),
"ThirdPartyCaptchaSolver",
"third-party should be filtered out without an API key",
);
}
}
#[test]
fn custom_captcha_routes_through_third_party_when_key_present() {
use crate::provider::ProviderRegistry;
use crate::solver::ThirdPartyCaptchaSolver;
let registry = Arc::new(ProviderRegistry::with_built_in_rules().unwrap());
let mut chain = CaptchaSolverChain::empty().with_provider_registry(registry);
chain.add_solver(BehavioralCaptchaSolver::new());
chain.add_solver(ThirdPartyCaptchaSolver::two_captcha().with_api_key("test-key"));
let ordered = chain.ordered_solvers(
"datadome.test",
&CaptchaType::Custom("datadome".into()),
&crate::captcha_detect::DetectedCaptcha::Custom("datadome".into()),
);
assert_eq!(
ordered.len(),
2,
"with API key, both Behavioral and ThirdParty should be eligible",
);
assert_eq!(ordered[0].method(), SolveMethod::BehavioralBypass);
assert_eq!(ordered[1].method(), SolveMethod::ThirdPartyService);
}
#[test]
fn default_chain_timeout_is_sane() {
let chain = CaptchaSolverChain::default_chain();
assert_eq!(chain.config.per_solver_timeout_ms, 180_000);
assert!(chain.config.screenshot_on_failure);
assert!(chain.config.verify_outcome);
}
#[test]
fn oracle_for_kind_resolves_each_built_in_to_a_documented_oracle() {
use crate::captcha_detect::DetectedCaptcha as K;
for kind in [K::Turnstile, K::RecaptchaV2, K::RecaptchaV3, K::HCaptcha] {
assert!(
oracle_for_kind(&kind).is_some(),
"{kind:?} must have a token-shape oracle wired"
);
}
}
#[test]
fn oracle_for_kind_returns_none_for_kinds_without_documented_token_shape() {
use crate::captcha_detect::DetectedCaptcha as K;
for kind in [
K::SliderCaptcha,
K::CanvasCaptcha,
K::MultiStepCaptcha,
K::ImageCaptcha,
K::PowCaptcha,
] {
assert!(
oracle_for_kind(&kind).is_none(),
"{kind:?} must NOT have a token-shape oracle (token is app-defined)"
);
}
}
#[test]
fn oracle_for_kind_resolves_recaptcha_enterprise_custom_rule_to_enterprise_oracle() {
use crate::captcha_detect::DetectedCaptcha as K;
let oracle = oracle_for_kind(&K::Custom("recaptcha_enterprise".into()))
.expect("recaptcha_enterprise must resolve via Custom-name match");
assert_eq!(oracle.vendor(), "recaptcha-enterprise");
}
#[test]
fn oracle_for_kind_resolves_hcaptcha_enterprise_to_hcaptcha_oracle() {
use crate::captcha_detect::DetectedCaptcha as K;
let oracle = oracle_for_kind(&K::Custom("hcaptcha_enterprise".into()))
.expect("hcaptcha_enterprise must resolve");
assert_eq!(oracle.vendor(), "hcaptcha");
}
#[test]
fn oracle_for_kind_returns_none_for_unrecognised_custom_rule() {
use crate::captcha_detect::DetectedCaptcha as K;
assert!(oracle_for_kind(&K::Custom("totally_unknown_vendor".into())).is_none());
}
#[test]
fn verify_outcome_downgrades_success_when_oracle_disagrees() {
use crate::solver::oracle::{classify, OutcomeClassification, PageSnapshot};
let before = PageSnapshot {
url: "/x".into(),
title: "T".into(),
body_excerpt: "b".into(),
captcha_present: Some(true),
cookie_names: vec!["__cf_bm".into()],
};
let after_advanced = PageSnapshot {
captcha_present: Some(false),
cookie_names: vec!["__cf_bm".into(), "sessionid".into()],
..before.clone()
};
assert_eq!(
classify(&before, &after_advanced),
OutcomeClassification::Advanced
);
let after_recycled = PageSnapshot {
captcha_present: Some(true),
..before.clone()
};
assert_eq!(
classify(&before, &after_recycled),
OutcomeClassification::Recycled
);
let after_blocked = PageSnapshot {
captcha_present: Some(false),
body_excerpt: "access denied, bot detected".into(),
..before.clone()
};
assert_eq!(
classify(&before, &after_blocked),
OutcomeClassification::HardBlock
);
let after_silent = PageSnapshot {
captcha_present: Some(false),
..before.clone()
};
assert_eq!(
classify(&before, &after_silent),
OutcomeClassification::SilentFail
);
}
#[test]
fn chain_with_custom_config() {
let chain = CaptchaSolverChain::empty().with_config(ChainConfig {
per_solver_timeout_ms: 30_000,
screenshot_on_failure: false,
verify_outcome: true,
allow_unknown_outcome: false,
});
assert_eq!(chain.config.per_solver_timeout_ms, 30_000);
assert!(!chain.config.screenshot_on_failure);
assert!(chain.config.verify_outcome);
assert!(!chain.config.allow_unknown_outcome);
}
#[test]
fn ordered_solvers_puts_preferred_first() {
let mut chain = CaptchaSolverChain::empty();
chain.add_solver(BehavioralCaptchaSolver::new());
chain.add_solver(VlmCaptchaSolver::new());
chain.add_solver(AudioCaptchaSolver::new());
chain.patterns.record(
"example.com",
&CaptchaType::RecaptchaV2,
true,
1000,
SolveMethod::VisionLLM,
);
let ordered = chain.ordered_solvers(
"example.com",
&CaptchaType::RecaptchaV2,
&crate::captcha_detect::DetectedCaptcha::RecaptchaV2,
);
assert_eq!(ordered.len(), 3);
assert_eq!(ordered[0].name(), "VlmCaptchaSolver");
assert_eq!(ordered[1].name(), "BehavioralCaptchaSolver");
assert_eq!(ordered[2].name(), "AudioCaptchaSolver");
}
#[test]
fn ordered_solvers_keeps_original_order_when_no_preference() {
let chain = CaptchaSolverChain::default_chain();
let ordered = chain.ordered_solvers(
"unknown.com",
&CaptchaType::RecaptchaV2,
&crate::captcha_detect::DetectedCaptcha::RecaptchaV2,
);
assert_eq!(ordered[0].name(), "WaitForTokenSolver");
assert_eq!(ordered[1].name(), "BehavioralCaptchaSolver");
#[cfg(feature = "vision")]
{
assert_eq!(ordered[2].name(), "YoloGridSolver");
assert_eq!(ordered[3].name(), "VlmCaptchaSolver");
assert_eq!(ordered[4].name(), "RecaptchaAudioSolver");
assert_eq!(ordered[5].name(), "AudioCaptchaSolver");
}
#[cfg(not(feature = "vision"))]
{
assert_eq!(ordered[2].name(), "VlmCaptchaSolver");
assert_eq!(ordered[3].name(), "RecaptchaAudioSolver");
assert_eq!(ordered[4].name(), "AudioCaptchaSolver");
}
}
#[test]
fn custom_captcha_returns_no_solvers_without_provider_registry() {
let chain = CaptchaSolverChain::default_chain();
let ordered = chain.ordered_solvers(
"datadome.test",
&CaptchaType::Custom("datadome".into()),
&crate::captcha_detect::DetectedCaptcha::Custom("datadome".into()),
);
assert!(
ordered.is_empty(),
"without a ProviderRegistry the chain has no way to know which solver to try for a Custom captcha",
);
}
#[test]
fn custom_captcha_routes_through_provider_recommended_methods() {
use crate::provider::ProviderRegistry;
let registry = Arc::new(ProviderRegistry::with_built_in_rules().unwrap());
let chain = CaptchaSolverChain::default_chain().with_provider_registry(registry);
let ordered = chain.ordered_solvers(
"datadome.test",
&CaptchaType::Custom("datadome".into()),
&crate::captcha_detect::DetectedCaptcha::Custom("datadome".into()),
);
assert_eq!(
ordered.len(),
1,
"only one BehavioralBypass solver supports datadome; \
ThirdPartyService filters out without API key",
);
assert_eq!(ordered[0].name(), "SliderCaptchaSolver");
}
#[test]
fn custom_captcha_routes_to_vision_first_when_provider_recommends_it() {
use crate::provider::ProviderRegistry;
let registry = Arc::new(ProviderRegistry::with_built_in_rules().unwrap());
let chain = CaptchaSolverChain::default_chain().with_provider_registry(registry);
let ordered = chain.ordered_solvers(
"x.test",
&CaptchaType::Custom("perimeterx_human".into()),
&crate::captcha_detect::DetectedCaptcha::Custom("perimeterx_human".into()),
);
assert_eq!(ordered.len(), 1);
assert_eq!(ordered[0].method(), SolveMethod::BehavioralBypass);
}
#[test]
fn custom_captcha_with_unknown_name_returns_no_solvers() {
use crate::provider::ProviderRegistry;
let registry = Arc::new(ProviderRegistry::with_built_in_rules().unwrap());
let chain = CaptchaSolverChain::default_chain().with_provider_registry(registry);
let ordered = chain.ordered_solvers(
"unknown.test",
&CaptchaType::Custom("never-heard-of-it".into()),
&crate::captcha_detect::DetectedCaptcha::Custom("never-heard-of-it".into()),
);
assert!(
ordered.is_empty(),
"unknown Custom name has no provider entry → no solvers",
);
}
#[test]
fn ordered_solvers_keeps_original_order_when_preference_not_in_chain() {
let mut chain = CaptchaSolverChain::empty();
chain.add_solver(BehavioralCaptchaSolver::new());
chain.add_solver(VlmCaptchaSolver::new());
chain.patterns.record(
"example.com",
&CaptchaType::RecaptchaV2,
true,
1000,
SolveMethod::AudioBypass,
);
let ordered = chain.ordered_solvers(
"example.com",
&CaptchaType::RecaptchaV2,
&crate::captcha_detect::DetectedCaptcha::RecaptchaV2,
);
assert_eq!(ordered[0].name(), "BehavioralCaptchaSolver");
assert_eq!(ordered[1].name(), "VlmCaptchaSolver");
}
#[test]
fn ordered_solvers_filters_by_supports() {
let chain = CaptchaSolverChain::default_chain();
let ordered = chain.ordered_solvers(
"example.com",
&CaptchaType::HCaptcha,
&crate::captcha_detect::DetectedCaptcha::HCaptcha,
);
#[cfg(feature = "vision")]
{
assert_eq!(ordered.len(), 3);
assert_eq!(ordered[0].name(), "WaitForTokenSolver");
assert_eq!(ordered[1].name(), "YoloGridSolver");
assert_eq!(ordered[2].name(), "VlmCaptchaSolver");
}
#[cfg(not(feature = "vision"))]
{
assert_eq!(ordered.len(), 2);
assert_eq!(ordered[0].name(), "WaitForTokenSolver");
assert_eq!(ordered[1].name(), "VlmCaptchaSolver");
}
}
#[test]
fn ordered_solvers_empty_when_no_solver_supports() {
let chain = CaptchaSolverChain::default_chain();
let ordered = chain.ordered_solvers(
"example.com",
&CaptchaType::Custom("none".into()),
&crate::captcha_detect::DetectedCaptcha::None,
);
assert!(ordered.is_empty());
}