captchaforge 0.2.39

Captcha detection and solving for Firefox and BiDi-driven browsers. Detection, vendor solver scaffolding, trusted cross-origin click delivery into nested OOPIFs, and stealth personas are implemented and tested; broad live-vendor solve rates are not yet benchmarked.
Documentation
//! Unit tests for [`super`] (the solve-result cache + eviction).

use super::*;

#[test]
fn miss_returns_none() {
    let c = TokenCache::new();
    assert!(c
        .get("example.com", &CaptchaType::CloudflareTurnstile)
        .is_none());
}

#[test]
fn hit_returns_token_within_ttl() {
    let c = TokenCache::new();
    c.put(
        "example.com",
        &CaptchaType::CloudflareTurnstile,
        "tok123".into(),
        "BehavioralCaptchaSolver",
    );
    let got = c.get_token("example.com", &CaptchaType::CloudflareTurnstile);
    assert_eq!(got.as_deref(), Some("tok123"));
}

#[test]
fn expired_entry_returns_none() {
    let c = TokenCache::with_ttl(Duration::from_millis(1));
    c.put(
        "example.com",
        &CaptchaType::HCaptcha,
        "tok".into(),
        "VlmCaptchaSolver",
    );
    // Sleep just past TTL.
    std::thread::sleep(Duration::from_millis(5));
    assert!(c.get("example.com", &CaptchaType::HCaptcha).is_none());
}

#[test]
fn put_replaces_existing_entry() {
    let c = TokenCache::new();
    c.put(
        "x.test",
        &CaptchaType::HCaptcha,
        "old".into(),
        "VlmCaptchaSolver",
    );
    c.put(
        "x.test",
        &CaptchaType::HCaptcha,
        "new".into(),
        "VlmCaptchaSolver",
    );
    assert_eq!(
        c.get_token("x.test", &CaptchaType::HCaptcha).as_deref(),
        Some("new")
    );
    assert_eq!(c.len(), 1, "replace must not grow the map");
}

#[test]
fn invalidate_drops_only_the_targeted_entry() {
    let c = TokenCache::new();
    c.put("a.test", &CaptchaType::HCaptcha, "ta".into(), "Vlm");
    c.put("b.test", &CaptchaType::HCaptcha, "tb".into(), "Vlm");
    c.invalidate("a.test", &CaptchaType::HCaptcha);
    assert!(c.get("a.test", &CaptchaType::HCaptcha).is_none());
    assert!(c.get("b.test", &CaptchaType::HCaptcha).is_some());
}

#[test]
fn different_captcha_types_on_same_domain_are_isolated() {
    let c = TokenCache::new();
    c.put("d.test", &CaptchaType::HCaptcha, "h".into(), "Vlm");
    c.put(
        "d.test",
        &CaptchaType::CloudflareTurnstile,
        "t".into(),
        "Beh",
    );
    assert_eq!(
        c.get_token("d.test", &CaptchaType::HCaptcha).as_deref(),
        Some("h")
    );
    assert_eq!(
        c.get_token("d.test", &CaptchaType::CloudflareTurnstile)
            .as_deref(),
        Some("t")
    );
}

#[test]
fn put_with_ttl_overrides_default() {
    let c = TokenCache::with_ttl(Duration::from_secs(10));
    c.put_with_ttl(
        "x.test",
        &CaptchaType::HCaptcha,
        "tok".into(),
        "Vlm",
        Duration::from_millis(1),
    );
    std::thread::sleep(Duration::from_millis(5));
    assert!(
        c.get("x.test", &CaptchaType::HCaptcha).is_none(),
        "explicit short TTL wins"
    );
}

#[test]
fn put_with_cookies_round_trips_on_hit() {
    let c = TokenCache::new();
    let cookie = CapturedCookie {
        name: "datadome".into(),
        value: "abc".into(),
        domain: "x.test".into(),
        path: "/".into(),
        expires: None,
        secure: true,
        http_only: true,
        same_site: None,
    };
    c.put_full(
        "x.test",
        &CaptchaType::CloudflareTurnstile,
        "tok".into(),
        "Behavioral",
        Duration::from_secs(60),
        vec![cookie.clone()],
    );
    let entry = c.get("x.test", &CaptchaType::CloudflareTurnstile).unwrap();
    assert_eq!(entry.cookies().len(), 1);
    assert_eq!(entry.cookies()[0].name, "datadome");
}

#[test]
fn put_without_cookies_returns_empty_cookies_on_hit() {
    let c = TokenCache::new();
    c.put("x.test", &CaptchaType::HCaptcha, "t".into(), "Vlm");
    let entry = c.get("x.test", &CaptchaType::HCaptcha).unwrap();
    assert!(entry.cookies().is_empty());
}

#[test]
fn stats_initialise_to_zero() {
    let c = TokenCache::new();
    let s = c.stats();
    assert_eq!(s.hits, 0);
    assert_eq!(s.misses, 0);
    assert_eq!(s.expired_misses, 0);
    assert_eq!(s.puts, 0);
    assert_eq!(s.invalidations, 0);
    assert!(s.hit_rate().is_none(), "no lookups → no rate");
}

#[test]
fn stats_count_each_event_class_separately() {
    let c = TokenCache::new();
    // 1 put, 0 lookups so far.
    c.put("a.test", &CaptchaType::HCaptcha, "t".into(), "Vlm");
    assert_eq!(c.stats().puts, 1);

    // 2 hits.
    let _ = c.get("a.test", &CaptchaType::HCaptcha);
    let _ = c.get("a.test", &CaptchaType::HCaptcha);
    assert_eq!(c.stats().hits, 2);

    // 1 miss.
    let _ = c.get("never.test", &CaptchaType::HCaptcha);
    assert_eq!(c.stats().misses, 1);

    // 1 invalidation.
    c.invalidate("a.test", &CaptchaType::HCaptcha);
    assert_eq!(c.stats().invalidations, 1);

    // hit_rate = 2 / (2+1+0) = 0.667
    let s = c.stats();
    let r = s.hit_rate().unwrap();
    assert!(
        (r - 2.0 / 3.0).abs() < 0.001,
        "expected 0.667 hit rate; got {r}"
    );
}

#[test]
fn stats_track_expired_misses_distinct_from_misses() {
    let c = TokenCache::with_ttl(Duration::from_millis(1));
    c.put("x.test", &CaptchaType::HCaptcha, "t".into(), "Vlm");
    std::thread::sleep(Duration::from_millis(5));
    let _ = c.get("x.test", &CaptchaType::HCaptcha);
    let s = c.stats();
    assert_eq!(s.misses, 0, "key existed → not a plain miss");
    assert_eq!(s.expired_misses, 1, "key existed but expired");
}

#[test]
fn invalidate_on_missing_key_does_not_count() {
    let c = TokenCache::new();
    c.invalidate("missing.test", &CaptchaType::HCaptcha);
    assert_eq!(c.stats().invalidations, 0);
}

#[test]
fn reset_stats_zeroes_counters_but_keeps_entries() {
    let c = TokenCache::new();
    c.put("a.test", &CaptchaType::HCaptcha, "t".into(), "Vlm");
    let _ = c.get("a.test", &CaptchaType::HCaptcha);
    assert_eq!(c.stats().hits, 1);
    c.reset_stats();
    let s = c.stats();
    assert_eq!(s.hits, 0);
    assert_eq!(s.puts, 0);
    // Entry still in cache.
    assert!(c.get("a.test", &CaptchaType::HCaptcha).is_some());
}

#[test]
fn clear_removes_all_entries() {
    let c = TokenCache::new();
    c.put("a.test", &CaptchaType::HCaptcha, "ta".into(), "Vlm");
    c.put("b.test", &CaptchaType::HCaptcha, "tb".into(), "Vlm");
    assert_eq!(c.len(), 2);
    c.clear();
    assert!(c.is_empty());
}