captchaforge 0.2.0

Automatic CAPTCHA detection and multi-strategy solving for chromiumoxide-driven headless browsers (Cloudflare Turnstile, reCAPTCHA v2/v3, hCaptcha, image grids, audio, sliders).
Documentation
//! Behaviour contract for `PatternStore` — the crowd-sourced
//! per-domain memory of which solver method has worked. Pure-Rust
//! tests; no chromium needed.

use captchaforge::solver::{CaptchaPattern, CaptchaType, PatternStore, SolveMethod};
use std::sync::Arc;

#[test]
fn empty_store_returns_none_for_unknown_domain() {
    let s = PatternStore::default();
    assert!(s
        .best_method("never-seen.com", &CaptchaType::CloudflareTurnstile)
        .is_none());
}

#[test]
fn record_then_lookup_returns_the_method() {
    let s = PatternStore::default();
    s.record(
        "example.com",
        &CaptchaType::HCaptcha,
        true,
        500,
        SolveMethod::BehavioralBypass,
    );
    let got = s.best_method("example.com", &CaptchaType::HCaptcha);
    assert_eq!(got, Some(SolveMethod::BehavioralBypass));
}

#[test]
fn different_captcha_types_on_same_domain_are_keyed_independently() {
    let s = PatternStore::default();
    s.record(
        "x.com",
        &CaptchaType::CloudflareTurnstile,
        true,
        100,
        SolveMethod::BehavioralBypass,
    );
    s.record(
        "x.com",
        &CaptchaType::HCaptcha,
        true,
        200,
        SolveMethod::VisionLLM,
    );
    assert_eq!(
        s.best_method("x.com", &CaptchaType::CloudflareTurnstile),
        Some(SolveMethod::BehavioralBypass)
    );
    assert_eq!(
        s.best_method("x.com", &CaptchaType::HCaptcha),
        Some(SolveMethod::VisionLLM)
    );
}

#[test]
fn last_successful_method_replaces_best_method() {
    let s = PatternStore::default();
    s.record(
        "y.com",
        &CaptchaType::CloudflareTurnstile,
        true,
        100,
        SolveMethod::BehavioralBypass,
    );
    s.record(
        "y.com",
        &CaptchaType::CloudflareTurnstile,
        true,
        200,
        SolveMethod::VisionLLM,
    );
    assert_eq!(
        s.best_method("y.com", &CaptchaType::CloudflareTurnstile),
        Some(SolveMethod::VisionLLM)
    );
}

#[test]
fn failures_do_not_overwrite_best_method() {
    let s = PatternStore::default();
    s.record(
        "z.com",
        &CaptchaType::CloudflareTurnstile,
        true,
        100,
        SolveMethod::BehavioralBypass,
    );
    s.record(
        "z.com",
        &CaptchaType::CloudflareTurnstile,
        false,
        9999,
        SolveMethod::AudioBypass,
    );
    assert_eq!(
        s.best_method("z.com", &CaptchaType::CloudflareTurnstile),
        Some(SolveMethod::BehavioralBypass),
        "AudioBypass failed; should NOT replace BehavioralBypass"
    );
}

#[test]
fn all_patterns_returns_one_entry_per_domain_type_pair() {
    let s = PatternStore::default();
    s.record(
        "a.com",
        &CaptchaType::HCaptcha,
        true,
        100,
        SolveMethod::VisionLLM,
    );
    s.record(
        "b.com",
        &CaptchaType::HCaptcha,
        true,
        100,
        SolveMethod::VisionLLM,
    );
    s.record(
        "a.com",
        &CaptchaType::CloudflareTurnstile,
        true,
        100,
        SolveMethod::BehavioralBypass,
    );
    let mut domains: Vec<String> = s.all_patterns().into_iter().map(|p| p.domain).collect();
    domains.sort();
    assert_eq!(domains, vec!["a.com", "a.com", "b.com"]);
}

#[test]
fn success_rate_converges_to_observed_proportion() {
    let mut p = CaptchaPattern::new(
        "ex.com",
        CaptchaType::CloudflareTurnstile,
        SolveMethod::BehavioralBypass,
    );
    for _ in 0..70 {
        p.record(true, 100, SolveMethod::BehavioralBypass);
    }
    for _ in 0..30 {
        p.record(false, 100, SolveMethod::BehavioralBypass);
    }
    assert_eq!(p.sample_count, 100);
    assert!(
        (p.success_rate - 0.70).abs() < 1e-3,
        "after 70/30 successes, expected ≈ 0.70, got {}",
        p.success_rate
    );
}

#[test]
fn avg_solve_time_converges_to_observed_mean() {
    let mut p = CaptchaPattern::new(
        "ex.com",
        CaptchaType::CloudflareTurnstile,
        SolveMethod::BehavioralBypass,
    );
    for ms in [100u64, 200, 300, 400, 500] {
        p.record(true, ms, SolveMethod::BehavioralBypass);
    }
    assert_eq!(p.sample_count, 5);
    assert_eq!(
        p.avg_solve_time, 300,
        "mean of 100..500 step 100 is 300, got {}",
        p.avg_solve_time
    );
}

#[test]
fn pattern_store_is_send_and_sync() {
    fn assert_send_sync<T: Send + Sync>() {}
    assert_send_sync::<PatternStore>();
}

#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
async fn concurrent_records_do_not_drop_observations() {
    let s = Arc::new(PatternStore::default());
    let mut handles = Vec::new();
    for thread_id in 0..8u64 {
        let s = s.clone();
        handles.push(tokio::spawn(async move {
            for _ in 0..125 {
                s.record(
                    "concurrent.com",
                    &CaptchaType::CloudflareTurnstile,
                    true,
                    100 + thread_id * 10,
                    SolveMethod::BehavioralBypass,
                );
            }
        }));
    }
    for h in handles {
        h.await.unwrap();
    }
    let patterns = s.all_patterns();
    assert_eq!(patterns.len(), 1, "single domain+type → single pattern");
    assert_eq!(
        patterns[0].sample_count, 1000,
        "8 threads × 125 records = 1000 (no lost updates)"
    );
}

#[test]
fn pattern_clone_is_independent_of_original() {
    let mut p1 = CaptchaPattern::new(
        "ex.com",
        CaptchaType::HCaptcha,
        SolveMethod::BehavioralBypass,
    );
    p1.record(true, 100, SolveMethod::BehavioralBypass);
    let p2 = p1.clone();
    p1.record(true, 200, SolveMethod::BehavioralBypass);
    assert_eq!(
        p2.sample_count, 1,
        "clone must not share state with original"
    );
    assert_eq!(p1.sample_count, 2);
}