use std::sync::{Arc, Mutex};
use captchaforge::captcha_detect::{CaptchaInfo, DetectedCaptcha};
use captchaforge::solver::{CaptchaSolverChain, CaptchaType, SolveMethod, TokenCache};
use captchaforge::telemetry::{SolveEvent, SolveOutcome, SolverTelemetry};
#[derive(Default)]
struct CapturingTelemetry {
events: Mutex<Vec<RecordedEvent>>,
}
#[derive(Debug, Clone)]
#[allow(dead_code)] struct RecordedEvent {
solver: &'static str,
outcome: SolveOutcome,
domain: String,
method: SolveMethod,
time_ms: u64,
}
impl SolverTelemetry for CapturingTelemetry {
fn record(&self, evt: &SolveEvent<'_>) {
self.events.lock().unwrap().push(RecordedEvent {
solver: evt.solver,
outcome: evt.outcome,
domain: evt.domain.to_owned(),
method: evt.method.clone(),
time_ms: evt.time_ms,
});
}
}
fn turnstile_info(domain: &str) -> CaptchaInfo {
CaptchaInfo {
kind: DetectedCaptcha::Turnstile,
site_key: Some("0x4AAAAAAA".into()),
page_url: format!("https://{domain}/login"),
container_selector: Some(".cf-turnstile".into()),
}
}
#[test]
fn cache_miss_returns_none_and_emits_no_telemetry() {
let telemetry = Arc::new(CapturingTelemetry::default());
let cache = Arc::new(TokenCache::new());
let chain = CaptchaSolverChain::default_chain()
.with_token_cache(cache)
.with_telemetry(telemetry.clone());
let info = turnstile_info("example.com");
assert!(chain.cached_solution(&info).is_none());
assert!(
telemetry.events.lock().unwrap().is_empty(),
"telemetry must NOT fire on cache miss",
);
}
#[test]
fn cache_hit_returns_token_and_emits_one_success_event() {
let telemetry = Arc::new(CapturingTelemetry::default());
let cache = Arc::new(TokenCache::new());
cache.put(
"example.com",
&CaptchaType::CloudflareTurnstile,
"tok-123".into(),
"BehavioralCaptchaSolver",
);
let chain = CaptchaSolverChain::default_chain()
.with_token_cache(cache)
.with_telemetry(telemetry.clone());
let info = turnstile_info("example.com");
let got = chain
.cached_solution(&info)
.expect("cache hit must return Some");
assert!(got.success);
assert_eq!(got.solution, "tok-123");
assert_eq!(got.method, SolveMethod::CrowdSourced);
assert!(got.confidence >= 0.99);
let events = telemetry.events.lock().unwrap();
assert_eq!(events.len(), 1, "exactly one telemetry event per cache hit");
let evt = &events[0];
assert_eq!(evt.solver, "TokenCache");
assert_eq!(evt.outcome, SolveOutcome::Success);
assert_eq!(evt.domain, "example.com");
assert_eq!(evt.method, SolveMethod::CrowdSourced);
}
#[test]
fn cache_hit_isolates_by_domain() {
let telemetry = Arc::new(CapturingTelemetry::default());
let cache = Arc::new(TokenCache::new());
cache.put(
"a.test",
&CaptchaType::CloudflareTurnstile,
"ta".into(),
"BehavioralCaptchaSolver",
);
let chain = CaptchaSolverChain::default_chain()
.with_token_cache(cache)
.with_telemetry(telemetry.clone());
assert_eq!(
chain
.cached_solution(&turnstile_info("a.test"))
.map(|r| r.solution),
Some("ta".into()),
"domain a.test must hit",
);
assert!(
chain.cached_solution(&turnstile_info("b.test")).is_none(),
"domain b.test must miss (different key)",
);
}
#[test]
fn invalidating_cache_makes_next_call_miss() {
let telemetry = Arc::new(CapturingTelemetry::default());
let cache = Arc::new(TokenCache::new());
cache.put(
"x.test",
&CaptchaType::CloudflareTurnstile,
"tok".into(),
"BehavioralCaptchaSolver",
);
let chain = CaptchaSolverChain::default_chain()
.with_token_cache(cache.clone())
.with_telemetry(telemetry);
assert!(chain.cached_solution(&turnstile_info("x.test")).is_some());
cache.invalidate("x.test", &CaptchaType::CloudflareTurnstile);
assert!(
chain.cached_solution(&turnstile_info("x.test")).is_none(),
"post-invalidate call must miss",
);
}
#[test]
fn chain_with_no_cache_returns_none_for_cached_solution() {
let telemetry = Arc::new(CapturingTelemetry::default());
let chain = CaptchaSolverChain::default_chain().with_telemetry(telemetry.clone());
assert!(chain.cached_solution(&turnstile_info("any.test")).is_none());
assert!(
telemetry.events.lock().unwrap().is_empty(),
"no cache, no telemetry",
);
}
#[test]
fn with_telemetry_actually_installs_the_instance() {
let telemetry = Arc::new(CapturingTelemetry::default());
let cache = Arc::new(TokenCache::new());
cache.put(
"z.test",
&CaptchaType::CloudflareTurnstile,
"z".into(),
"BehavioralCaptchaSolver",
);
let chain = CaptchaSolverChain::default_chain()
.with_token_cache(cache)
.with_telemetry(telemetry.clone());
chain.cached_solution(&turnstile_info("z.test")).unwrap();
assert_eq!(
telemetry.events.lock().unwrap().len(),
1,
"telemetry must be the installed instance, not a swallowed default",
);
}
#[test]
fn with_token_cache_actually_installs_the_cache() {
let cache = Arc::new(TokenCache::new());
cache.put(
"y.test",
&CaptchaType::CloudflareTurnstile,
"y-tok".into(),
"BehavioralCaptchaSolver",
);
let chain = CaptchaSolverChain::default_chain().with_token_cache(cache);
let got = chain
.cached_solution(&turnstile_info("y.test"))
.expect("cache must be installed");
assert_eq!(got.solution, "y-tok");
}