use super::*;
#[test]
fn service_base_urls() {
assert_eq!(
ThirdPartyService::TwoCaptcha.base_url(),
"https://2captcha.com"
);
assert_eq!(
ThirdPartyService::CapMonster.base_url(),
"https://api.capmonster.cloud"
);
assert_eq!(
ThirdPartyService::CapSolver.base_url(),
"https://api.capsolver.com"
);
assert_eq!(
ThirdPartyService::Custom {
base_url: "https://my.gw".into()
}
.base_url(),
"https://my.gw"
);
}
#[test]
fn no_api_key_means_no_support() {
let s = ThirdPartyCaptchaSolver {
client: reqwest::Client::new(),
service: ThirdPartyService::TwoCaptcha,
api_key: None,
poll_interval_ms: 1,
max_polls: 1,
key_pool: None,
};
assert!(!s.has_api_key());
assert!(!s.supports(&DetectedCaptcha::Turnstile));
assert!(!s.supports(&DetectedCaptcha::RecaptchaV2));
assert!(!s.supports(&DetectedCaptcha::HCaptcha));
}
#[test]
fn with_api_key_enables_support_for_known_kinds() {
let s = ThirdPartyCaptchaSolver {
client: reqwest::Client::new(),
service: ThirdPartyService::TwoCaptcha,
api_key: Some("test-key".into()),
poll_interval_ms: 1,
max_polls: 1,
key_pool: None,
};
assert!(s.supports(&DetectedCaptcha::Turnstile));
assert!(s.supports(&DetectedCaptcha::RecaptchaV2));
assert!(s.supports(&DetectedCaptcha::RecaptchaV3));
assert!(s.supports(&DetectedCaptcha::HCaptcha));
assert!(s.supports(&DetectedCaptcha::Custom("datadome".into())));
assert!(s.supports(&DetectedCaptcha::Custom("arkose_funcaptcha".into())));
assert!(s.supports(&DetectedCaptcha::Custom("geetest_v3".into())));
assert!(s.supports(&DetectedCaptcha::Custom("geetest_v4".into())));
}
#[test]
fn with_api_key_does_not_support_unknown_custom_or_unsupported_kinds() {
let s = ThirdPartyCaptchaSolver {
client: reqwest::Client::new(),
service: ThirdPartyService::TwoCaptcha,
api_key: Some("k".into()),
poll_interval_ms: 1,
max_polls: 1,
key_pool: None,
};
assert!(!s.supports(&DetectedCaptcha::Custom("never_heard_of_it".into())));
assert!(!s.supports(&DetectedCaptcha::PowCaptcha));
assert!(!s.supports(&DetectedCaptcha::SliderCaptcha));
assert!(!s.supports(&DetectedCaptcha::CanvasCaptcha));
assert!(!s.supports(&DetectedCaptcha::None));
assert!(!s.supports(&DetectedCaptcha::Custom("aws_waf_captcha".into())));
}
fn info(kind: DetectedCaptcha, sitekey: Option<&str>) -> CaptchaInfo {
CaptchaInfo {
kind,
site_key: sitekey.map(String::from),
page_url: "https://example.com/login".into(),
container_selector: None,
}
}
#[test]
fn task_params_turnstile_uses_method_turnstile_with_sitekey() {
let p = ThirdPartyCaptchaSolver::build_task_params(&info(
DetectedCaptcha::Turnstile,
Some("0x4AAAAAAA-test"),
))
.unwrap();
assert!(p.iter().any(|(k, v)| *k == "method" && v == "turnstile"));
assert!(p
.iter()
.any(|(k, v)| *k == "sitekey" && v == "0x4AAAAAAA-test"));
assert!(p.iter().any(|(k, _)| *k == "pageurl"));
}
#[test]
fn task_params_recaptcha_v2_uses_googlekey() {
let p = ThirdPartyCaptchaSolver::build_task_params(&info(
DetectedCaptcha::RecaptchaV2,
Some("6Lc-test"),
))
.unwrap();
assert!(p
.iter()
.any(|(k, v)| *k == "method" && v == "userrecaptcha"));
assert!(p.iter().any(|(k, v)| *k == "googlekey" && v == "6Lc-test"));
}
#[test]
fn task_params_recaptcha_v3_includes_version() {
let p = ThirdPartyCaptchaSolver::build_task_params(&info(
DetectedCaptcha::RecaptchaV3,
Some("6Lc-v3-test"),
))
.unwrap();
assert!(p.iter().any(|(k, v)| *k == "version" && v == "v3"));
assert!(p.iter().any(|(k, _)| *k == "min_score"));
}
#[test]
fn task_params_hcaptcha_uses_hcaptcha_method() {
let p =
ThirdPartyCaptchaSolver::build_task_params(&info(DetectedCaptcha::HCaptcha, Some("hsk")))
.unwrap();
assert!(p.iter().any(|(k, v)| *k == "method" && v == "hcaptcha"));
}
#[test]
fn task_params_datadome_works_without_sitekey() {
let mut info = info(DetectedCaptcha::Custom("datadome".into()), None);
info.container_selector = Some("https://geo.captcha-delivery.com/captcha?...".into());
let p = ThirdPartyCaptchaSolver::build_task_params(&info).unwrap();
assert!(p.iter().any(|(k, v)| *k == "method" && v == "datadome"));
assert!(p.iter().any(|(k, _)| *k == "captcha_url"));
}
#[test]
fn task_params_funcaptcha_uses_publickey() {
let p = ThirdPartyCaptchaSolver::build_task_params(&info(
DetectedCaptcha::Custom("arkose_funcaptcha".into()),
Some("AB12CD34"),
))
.unwrap();
assert!(p.iter().any(|(k, v)| *k == "method" && v == "funcaptcha"));
assert!(p.iter().any(|(k, v)| *k == "publickey" && v == "AB12CD34"));
}
#[test]
fn task_params_returns_none_for_kinds_without_a_method() {
assert!(
ThirdPartyCaptchaSolver::build_task_params(&info(DetectedCaptcha::PowCaptcha, None))
.is_none()
);
assert!(ThirdPartyCaptchaSolver::build_task_params(&info(
DetectedCaptcha::SliderCaptcha,
None
))
.is_none());
assert!(
ThirdPartyCaptchaSolver::build_task_params(&info(DetectedCaptcha::None, None)).is_none()
);
assert!(ThirdPartyCaptchaSolver::build_task_params(&info(
DetectedCaptcha::Custom("zomg_unknown".into()),
Some("k")
))
.is_none());
}
#[test]
fn task_params_returns_none_when_required_sitekey_missing() {
assert!(
ThirdPartyCaptchaSolver::build_task_params(&info(DetectedCaptcha::Turnstile, None))
.is_none()
);
assert!(
ThirdPartyCaptchaSolver::build_task_params(&info(DetectedCaptcha::RecaptchaV2, None))
.is_none()
);
assert!(
ThirdPartyCaptchaSolver::build_task_params(&info(DetectedCaptcha::HCaptcha, None))
.is_none()
);
}
#[test]
fn builders_set_fields() {
let s = ThirdPartyCaptchaSolver::two_captcha()
.with_api_key("k123")
.with_poll_interval_ms(2000)
.with_max_polls(10);
assert_eq!(s.api_key.as_deref(), Some("k123"));
assert_eq!(s.poll_interval_ms, 2000);
assert_eq!(s.max_polls, 10);
}
#[tokio::test]
async fn retry_succeeds_first_try_on_immediate_ok() {
let attempts = std::sync::atomic::AtomicU32::new(0);
let r: Result<i32> = retry_transient("test", || async {
attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok::<_, RetryError>(42)
})
.await;
assert_eq!(r.unwrap(), 42);
assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 1);
}
#[tokio::test]
async fn retry_recovers_after_transient_failure() {
let attempts = std::sync::atomic::AtomicU32::new(0);
let r: Result<&'static str> = retry_transient("test", || async {
let n = attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if n == 0 {
Err(RetryError::Transient(anyhow!("blip")))
} else {
Ok::<_, RetryError>("recovered")
}
})
.await;
assert_eq!(r.unwrap(), "recovered");
assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 2);
}
#[tokio::test]
async fn retry_exhausts_after_all_transient_failures() {
let attempts = std::sync::atomic::AtomicU32::new(0);
let r: Result<()> = retry_transient("test", || async {
attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Err(RetryError::Transient(anyhow!("always fails")))
})
.await;
assert!(r.is_err());
assert_eq!(
attempts.load(std::sync::atomic::Ordering::SeqCst),
TRANSIENT_RETRY_ATTEMPTS
);
}
#[tokio::test]
async fn retry_short_circuits_on_terminal_error() {
let attempts = std::sync::atomic::AtomicU32::new(0);
let r: Result<()> = retry_transient("test", || async {
attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Err(RetryError::Terminal(anyhow!("bad key")))
})
.await;
assert!(r.is_err());
assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 1);
}
#[test]
fn terminal_error_codes_match_2captcha_docs() {
for code in [
"ERROR_KEY_DOES_NOT_EXIST",
"ERROR_ZERO_BALANCE",
"ERROR_WRONG_USER_KEY",
"IP_BANNED",
"ERROR_CAPTCHA_UNSOLVABLE",
"ERROR_GOOGLEKEY",
] {
assert!(is_terminal_error(code), "should be terminal: {code}");
}
}
#[test]
fn capcha_not_ready_is_not_terminal() {
assert!(!is_terminal_error("CAPCHA_NOT_READY"));
assert!(!is_terminal_error(""));
assert!(!is_terminal_error("UNKNOWN_CODE_FROM_NEW_VENDOR"));
}
#[test]
fn method_and_name_are_stable() {
let s = ThirdPartyCaptchaSolver {
client: reqwest::Client::new(),
service: ThirdPartyService::TwoCaptcha,
api_key: Some("k".into()),
poll_interval_ms: 1,
max_polls: 1,
key_pool: None,
};
assert_eq!(s.name(), "ThirdPartyCaptchaSolver");
assert_eq!(s.method(), SolveMethod::ThirdPartyService);
}
#[test]
fn key_pool_round_robins_in_order() {
let pool = KeyPool::new(vec!["a".into(), "b".into(), "c".into()]);
assert_eq!(pool.current(), "a");
assert_eq!(pool.current(), "a");
assert_eq!(pool.advance(), "b");
assert_eq!(pool.advance(), "c");
assert_eq!(pool.advance(), "a");
assert_eq!(pool.advance(), "b");
}
#[test]
fn empty_key_pool_returns_empty_string() {
let pool = KeyPool::new(vec![]);
assert_eq!(pool.current(), "");
assert_eq!(pool.advance(), "");
}
#[test]
fn with_api_key_pool_seeds_first_key_and_supports_rotation() {
let mut s = ThirdPartyCaptchaSolver::two_captcha().with_api_key_pool([
"k1".to_string(),
"k2".into(),
"k3".into(),
]);
assert_eq!(s.api_key.as_deref(), Some("k1"));
let next = s.rotate_api_key().expect("pool exists");
assert_eq!(next, "k2");
assert_eq!(s.api_key.as_deref(), Some("k2"));
assert_eq!(s.rotate_api_key().unwrap(), "k3");
assert_eq!(s.rotate_api_key().unwrap(), "k1"); }
#[test]
fn empty_pool_disables_rotation_entirely() {
let v: Vec<String> = vec![];
let mut s = ThirdPartyCaptchaSolver::two_captcha().with_api_key_pool(v);
assert!(s.rotate_api_key().is_none());
}
#[test]
fn with_api_key_clears_any_pool() {
let mut s = ThirdPartyCaptchaSolver::two_captcha()
.with_api_key_pool(["k1".to_string(), "k2".into()])
.with_api_key("override");
assert_eq!(s.api_key.as_deref(), Some("override"));
assert!(s.rotate_api_key().is_none(), "with_api_key must clear pool");
}