captchaforge 0.2.9

Automatic CAPTCHA detection and multi-strategy solving for chromiumoxide-driven headless browsers (Cloudflare Turnstile, reCAPTCHA v2/v3, hCaptcha, image grids, audio, sliders).
Documentation
//! Integration tests for the chain ↔ cache ↔ telemetry wires.
//!
//! Full `solve()` end-to-end requires a real `chromiumoxide::Page`
//! and lives in `tests/integration_browser.rs`. These tests exercise
//! the cache short-circuit + telemetry firing in isolation via
//! [`CaptchaSolverChain::cached_solution`] — the same code path that
//! `solve()` runs as its first step.
//!
//! Coverage:
//!   * cache miss → cached_solution returns None, no telemetry event
//!   * cache hit  → cached_solution returns Some, ONE telemetry event
//!     with outcome Success, solver "TokenCache", method CrowdSourced
//!   * different (domain, captcha_type) keys are isolated
//!   * cache invalidate makes the next call miss again
//!   * `with_token_cache` and `with_telemetry` actually attach the
//!     installed instances (regression: I once shipped a builder
//!     that swallowed the argument)
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)] // captured for assertions; some fields read in subset of tests
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() {
    // Regression: confirm that with_telemetry connects the sink.
    // Without this test, a builder bug that silently swallowed the
    // argument (e.g. forgetting `self.telemetry = ...`) would leave
    // every event going to NoopTelemetry without a compile error.
    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() {
    // Same regression shape as with_telemetry: confirm builder wires
    // the cache, otherwise cache hits silently never fire.
    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");
}