use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use async_trait::async_trait;
use crawlberg::{
BrowserMode, BypassProvider, BypassResponse, CrawlConfig, CrawlEngine, CrawlError, DispatchProfile,
EscalationStrategy,
};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[derive(Debug)]
struct CountingMockProvider {
response: BypassResponse,
calls: AtomicU32,
}
impl CountingMockProvider {
fn new(body: &str) -> Arc<Self> {
let html = format!("<html><body>{body}</body></html>");
Arc::new(Self {
response: BypassResponse {
status: 200,
content_type: "text/html".into(),
body: html.clone(),
body_bytes: html.into_bytes(),
headers: Default::default(),
final_url: String::new(),
cost_usd: Some(0.0015),
vendor_request_id: None,
},
calls: AtomicU32::new(0),
})
}
fn calls(&self) -> u32 {
self.calls.load(Ordering::SeqCst)
}
}
#[async_trait]
impl BypassProvider for CountingMockProvider {
async fn fetch(&self, _url: &str) -> Result<BypassResponse, CrawlError> {
self.calls.fetch_add(1, Ordering::SeqCst);
Ok(self.response.clone())
}
fn vendor_name(&self) -> &'static str {
"mock"
}
}
fn build_engine(provider: Arc<CountingMockProvider>, soft_http_errors: bool) -> CrawlEngine {
let config = CrawlConfig {
soft_http_errors,
browser: crawlberg::BrowserConfig {
mode: BrowserMode::Never,
..Default::default()
},
dispatch: Some(DispatchProfile {
strategy: EscalationStrategy::BypassThenBrowser,
bypass: Some(provider as _),
..DispatchProfile::default()
}),
..CrawlConfig::default()
};
CrawlEngine::builder().config(config).build().unwrap()
}
#[tokio::test]
async fn soft_http_403_does_not_escalate_when_soft_errors_enabled() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/forbidden"))
.respond_with(ResponseTemplate::new(403).set_body_string("forbidden"))
.mount(&mock)
.await;
let provider = CountingMockProvider::new("bypass content");
let engine = build_engine(provider.clone(), true);
let result = engine.scrape(&format!("{}/forbidden", mock.uri())).await;
assert!(
result.is_ok(),
"soft_http_errors = true must convert 403 to Ok; got Err: {:?}",
result.err()
);
let page = result.unwrap();
assert_eq!(
page.status_code, 403,
"synthesised response must carry status_code = 403"
);
assert_eq!(
provider.calls(),
0,
"bypass must NOT be called when soft_http_errors short-circuits escalation"
);
}
#[tokio::test]
async fn soft_http_403_does_escalate_when_soft_errors_disabled() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/forbidden"))
.respond_with(ResponseTemplate::new(403).set_body_string("forbidden"))
.mount(&mock)
.await;
let provider = CountingMockProvider::new("bypass content");
let engine = build_engine(provider.clone(), false);
let result = engine.scrape(&format!("{}/forbidden", mock.uri())).await;
assert!(
result.is_ok(),
"soft_http_errors = false + BypassThenBrowser: bypass success must propagate as Ok; \
got Err: {:?}",
result.err()
);
assert_eq!(
provider.calls(),
1,
"bypass must be called exactly once when soft_http_errors = false \
and BypassThenBrowser strategy is active"
);
}