use std::sync::Arc;
use captchaforge::detect::DetectedCaptcha;
use captchaforge::solver::token_shapes::{for_vendor, TokenShape};
use captchaforge::solver::CaptchaSolverChain;
use captchaforge::telemetry::{MetricsTelemetry, SolverTelemetry};
use captchaforge::training_corpus::TrainingCorpus;
use tempfile::tempdir;
#[test]
fn token_oracle_classifies_decoy_as_decoy() {
let oracle = for_vendor("cloudflare-turnstile").expect("CF Turnstile oracle is wired");
assert_eq!(oracle.classify("FAILED"), TokenShape::Decoy);
assert_eq!(oracle.classify(""), TokenShape::Decoy);
}
#[test]
fn token_oracle_resolves_for_every_built_in_vendor() {
for vendor in [
"cloudflare-turnstile",
"recaptcha-v2",
"recaptcha-v3",
"recaptcha-enterprise",
"hcaptcha",
] {
assert!(
for_vendor(vendor).is_some(),
"{vendor} must have a wired token oracle (chain depends on it)"
);
}
}
#[test]
fn token_oracle_classifies_realistic_synthetic_token_as_plausible() {
let oracle = for_vendor("cloudflare-turnstile").unwrap();
let mut tok = String::from("0.");
for i in 0..248u32 {
const ALPH: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
tok.push(ALPH[(i * 17 % 64) as usize] as char);
}
assert_eq!(oracle.classify(&tok), TokenShape::Plausible);
}
#[test]
fn chain_with_training_corpus_builds_without_panic() {
let tmp = tempdir().unwrap();
let corpus = Arc::new(TrainingCorpus::open(tmp.path()).unwrap());
let _chain = CaptchaSolverChain::default_chain().with_training_corpus(corpus);
}
#[test]
fn training_corpus_round_trips_a_failure_sample() {
use captchaforge::training_corpus::TrainingSample;
let tmp = tempdir().unwrap();
let corpus = TrainingCorpus::open(tmp.path()).unwrap();
let sample = TrainingSample {
solver: "(chain-terminal)".into(),
vendor: "cloudflare-turnstile".into(),
detected_kind: "Turnstile".into(),
url: "https://example.com".into(),
outcome: "failure".into(),
confidence: None,
time_ms: 1234,
screenshot_b64: None,
dom_snapshot: None,
verified_outcome: None,
captured_at_unix: 1_700_000_000_i64,
};
corpus.append(&sample).unwrap();
let back = corpus.load_vendor("cloudflare-turnstile").unwrap();
assert_eq!(back.len(), 1);
assert_eq!(back[0].outcome, "failure");
assert_eq!(back[0].solver, "(chain-terminal)");
}
#[test]
fn training_corpus_aggregates_per_vendor_counts() {
use captchaforge::training_corpus::TrainingSample;
let tmp = tempdir().unwrap();
let corpus = TrainingCorpus::open(tmp.path()).unwrap();
fn sample(vendor: &str) -> TrainingSample {
TrainingSample {
solver: "x".into(),
vendor: vendor.into(),
detected_kind: "x".into(),
url: "x".into(),
outcome: "x".into(),
confidence: None,
time_ms: 0,
screenshot_b64: None,
dom_snapshot: None,
verified_outcome: None,
captured_at_unix: 0,
}
}
for _ in 0..3 {
corpus.append(&sample("cf")).unwrap();
}
for _ in 0..7 {
corpus.append(&sample("hcaptcha")).unwrap();
}
let counts = corpus.vendor_counts().unwrap();
let map: std::collections::HashMap<_, _> = counts.into_iter().collect();
assert_eq!(map.get("cf"), Some(&3));
assert_eq!(map.get("hcaptcha"), Some(&7));
}
#[tokio::test]
async fn hot_reload_registry_loads_initial_extras_from_dir() {
use captchaforge::rule_watcher::HotReloadRegistry;
let tmp = tempdir().unwrap();
let path = tmp.path().to_path_buf();
std::fs::write(
path.join("vendor_x.toml"),
r#"
[[provider]]
name = "vendor_x_test"
priority = 200
[provider.triggers]
selectors = [".vendor-x"]
"#,
)
.unwrap();
let watcher = HotReloadRegistry::start(&path, std::time::Duration::from_secs(60)).unwrap();
let registry = watcher.current();
let names: Vec<&'static str> = registry.providers().iter().map(|p| p.name()).collect();
assert!(
names.contains(&"vendor_x_test"),
"initial load missed vendor_x_test: {names:?}"
);
}
#[tokio::test]
async fn hot_reload_registry_atomic_swap_when_new_file_lands() {
use captchaforge::rule_watcher::HotReloadRegistry;
let tmp = tempdir().unwrap();
let path = tmp.path().to_path_buf();
std::fs::write(
path.join("v1.toml"),
r#"
[[provider]]
name = "wired_v1"
priority = 200
[provider.triggers]
selectors = [".v1"]
"#,
)
.unwrap();
let watcher = HotReloadRegistry::start(&path, std::time::Duration::from_secs(60)).unwrap();
assert_eq!(watcher.reload_count(), 0);
tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
std::fs::write(
path.join("v2.toml"),
r#"
[[provider]]
name = "wired_v2"
priority = 201
[provider.triggers]
selectors = [".v2"]
"#,
)
.unwrap();
assert!(watcher.poll_once(), "poll must detect new file");
assert_eq!(watcher.reload_count(), 1);
let names: Vec<&'static str> = watcher
.current()
.providers()
.iter()
.map(|p| p.name())
.collect();
assert!(
names.contains(&"wired_v1"),
"v1 should still be present after reload"
);
assert!(names.contains(&"wired_v2"), "v2 should appear after reload");
}
#[test]
fn metrics_telemetry_records_via_dyn_solver_telemetry() {
let metrics = Arc::new(MetricsTelemetry::new());
let dyn_t: Arc<dyn SolverTelemetry> = metrics.clone();
let kind = DetectedCaptcha::Turnstile;
let captcha_type = captchaforge::solver::CaptchaType::CloudflareTurnstile;
let method = captchaforge::solver::SolveMethod::BehavioralBypass;
dyn_t.record(&captchaforge::telemetry::SolveEvent::new(
"TestSolver",
&captcha_type,
&kind,
"ex.com",
captchaforge::telemetry::SolveOutcome::Success,
100,
Some(0.9),
&method,
));
let snap = metrics.snapshot();
assert_eq!(
snap.counts
.iter()
.find(|c| c.solver == "TestSolver" && c.outcome == "success")
.map(|c| c.count),
Some(1)
);
}
#[test]
fn metrics_snapshot_renders_valid_prometheus_format() {
let metrics = MetricsTelemetry::new();
let kind = DetectedCaptcha::Turnstile;
let captcha_type = captchaforge::solver::CaptchaType::CloudflareTurnstile;
let method = captchaforge::solver::SolveMethod::BehavioralBypass;
metrics.record(&captchaforge::telemetry::SolveEvent::new(
"TestSolver",
&captcha_type,
&kind,
"ex.com",
captchaforge::telemetry::SolveOutcome::Success,
100,
Some(0.9),
&method,
));
let prom = metrics.snapshot().to_prometheus();
assert!(prom.contains("# HELP captchaforge_solve_total"));
assert!(prom.contains("# TYPE captchaforge_solve_total counter"));
assert!(prom.contains("# TYPE captchaforge_solve_duration_ms histogram"));
assert!(prom.ends_with('\n'));
}
#[test]
fn planner_outcome_helpers_match_terminal_state() {
use captchaforge::solver::planner::{HopOutcome, PlannerOutcome, PlannerTerminal};
use captchaforge::solver::{CaptchaSolveResult, SolveMethod};
use std::time::Duration;
let success_outcome = PlannerOutcome {
hops: vec![HopOutcome {
hop_index: 1,
detected: DetectedCaptcha::Turnstile,
solve: CaptchaSolveResult {
success: true,
confidence: 1.0,
method: SolveMethod::BehavioralBypass,
time_ms: 100,
solution: "ok".into(),
screenshot: None,
cookies: Vec::new(),
verified_outcome: None,
},
}],
terminal: PlannerTerminal::PageCaptchaFree,
elapsed: Duration::from_millis(100),
};
assert!(success_outcome.fully_succeeded());
assert!(success_outcome.any_solve_succeeded());
}
#[test]
fn frame_graph_is_constructable_for_solver_consumers() {
use captchaforge::frame_graph::FrameGraph;
let g = FrameGraph::default();
assert!(g.bfs().is_empty());
assert!(!g.any_captcha_marker());
}