Current section

Files

Jump to
rbt_weave crates weave-content src verifier.rs
Raw

crates/weave-content/src/verifier.rs

use std::sync::Arc;
use std::time::Duration;
use dashmap::{DashMap, DashSet};
use tokio::sync::Semaphore;
use crate::entity::{Entity, FieldValue};
use crate::parser::ParseError;
use crate::relationship::Rel;
/// Maximum total URLs per verify run.
const MAX_URLS_PER_RUN: usize = 500_000;
/// Maximum redirect hops.
const MAX_REDIRECTS: usize = 5;
/// User-Agent header — intentionally generic to avoid bot detection.
const USER_AGENT: &str = "Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; Trident/6.0)";
/// HTTP status codes that are retryable.
const RETRYABLE_STATUS: [u16; 1] = [503];
/// Base delay for exponential backoff (1 second).
const RETRY_BASE_DELAY_MS: u64 = 1000;
/// Result of checking a single URL.
#[derive(Debug)]
pub struct UrlCheck {
pub url: String,
pub status: CheckStatus,
pub detail: Option<String>,
/// Whether this URL is a thumbnail (needs content-type check).
pub is_thumbnail: bool,
}
/// Severity level of a URL check result.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CheckStatus {
Ok,
/// 429 — rate limited. URL is likely fine, we just requested too much.
RateLimited,
/// 401/403 — suspicious. Site may be detecting us as a bot.
Suspicious,
Warn,
Error,
}
impl std::fmt::Display for CheckStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Ok => write!(f, "ok"),
Self::RateLimited => write!(f, "rate_limited"),
Self::Suspicious => write!(f, "suspicious"),
Self::Warn => write!(f, "warn"),
Self::Error => write!(f, "error"),
}
}
}
/// Collected URL to verify with its source location.
#[derive(Debug, Clone)]
pub struct UrlEntry {
url: String,
is_thumbnail: bool,
}
impl UrlEntry {
/// Create a new `UrlEntry`.
pub fn new(url: String, is_thumbnail: bool) -> Self {
Self { url, is_thumbnail }
}
/// URL accessor.
pub fn url(&self) -> &str {
&self.url
}
/// Thumbnail flag accessor.
pub fn is_thumbnail(&self) -> bool {
self.is_thumbnail
}
}
/// Collect thumbnail URLs from registry entities (people/organizations).
pub fn collect_registry_urls(reg: &crate::registry::EntityRegistry) -> Vec<UrlEntry> {
let mut urls = Vec::new();
let mut seen = std::collections::HashSet::new();
for name in reg.names() {
if let Some(entry) = reg.get_by_name(name) {
for (key, value) in &entry.entity.fields {
if matches!(key.as_str(), "thumbnail" | "thumbnail_source")
&& let FieldValue::Single(url) = value
&& !url.is_empty()
&& seen.insert(url.clone())
{
urls.push(UrlEntry {
url: url.clone(),
is_thumbnail: true,
});
}
}
}
}
urls
}
/// Collect all URLs from parsed case data for verification.
pub fn collect_urls(
sources: &[crate::parser::SourceEntry],
entities: &[Entity],
rels: &[Rel],
errors: &mut Vec<ParseError>,
) -> Vec<UrlEntry> {
let mut urls = Vec::new();
// Front matter sources
for source in sources {
urls.push(UrlEntry {
url: source.url().to_string(),
is_thumbnail: false,
});
}
// Entity URLs and thumbnails
for entity in entities {
for (key, value) in &entity.fields {
match key.as_str() {
"thumbnail" | "thumbnail_source" => {
if let FieldValue::Single(url) = value
&& !url.is_empty()
{
urls.push(UrlEntry {
url: url.clone(),
is_thumbnail: true,
});
}
}
"urls" => {
if let FieldValue::List(items) = value {
for url in items {
urls.push(UrlEntry {
url: url.clone(),
is_thumbnail: false,
});
}
}
}
_ => {}
}
}
}
// Relationship source URL overrides
for rel in rels {
for url in &rel.source_urls {
urls.push(UrlEntry {
url: url.clone(),
is_thumbnail: false,
});
}
}
// Deduplicate by URL
let mut seen = std::collections::HashSet::new();
urls.retain(|entry| seen.insert(entry.url.clone()));
// Boundary check
if urls.len() > MAX_URLS_PER_RUN {
errors.push(ParseError {
line: 0,
message: format!(
"too many URLs to verify (max {MAX_URLS_PER_RUN}, got {})",
urls.len()
),
});
}
urls
}
/// Extract domain from a URL for per-domain rate limiting.
fn extract_domain(url_str: &str) -> String {
url::Url::parse(url_str)
.ok()
.and_then(|u| u.host_str().map(str::to_lowercase))
.unwrap_or_default()
}
/// Shared state for domain-aware verification with learning.
struct VerifyContext {
client: reqwest::Client,
global_semaphore: Arc<Semaphore>,
domain_semaphores: DashMap<String, Arc<Semaphore>>,
domain_concurrency: usize,
/// Domains that returned 405 on HEAD — skip HEAD and go straight to GET.
head_rejected_domains: DashSet<String>,
max_retries: usize,
}
impl VerifyContext {
fn domain_semaphore(&self, domain: &str) -> Arc<Semaphore> {
self.domain_semaphores
.entry(domain.to_string())
.or_insert_with(|| Arc::new(Semaphore::new(self.domain_concurrency)))
.clone()
}
}
/// Verify all collected URLs concurrently with per-domain rate limiting and retries.
pub async fn verify_urls(
urls: Vec<UrlEntry>,
concurrency: usize,
timeout_secs: u64,
domain_concurrency: usize,
max_retries: usize,
) -> Vec<UrlCheck> {
let client = reqwest::Client::builder()
.user_agent(USER_AGENT)
.redirect(reqwest::redirect::Policy::limited(MAX_REDIRECTS))
.timeout(Duration::from_secs(timeout_secs))
.pool_max_idle_per_host(domain_concurrency)
.build()
.unwrap_or_else(|_| reqwest::Client::new());
let ctx = Arc::new(VerifyContext {
client,
global_semaphore: Arc::new(Semaphore::new(concurrency)),
domain_semaphores: DashMap::new(),
domain_concurrency,
head_rejected_domains: DashSet::new(),
max_retries,
});
let mut handles = Vec::with_capacity(urls.len());
for entry in urls {
let ctx = ctx.clone();
handles.push(tokio::spawn(async move {
let domain = extract_domain(&entry.url);
// Acquire both global and per-domain permits
let _global_permit = ctx.global_semaphore.acquire().await;
let domain_sem = ctx.domain_semaphore(&domain);
let _domain_permit = domain_sem.acquire().await;
check_url_with_retry(&ctx, &entry.url, entry.is_thumbnail).await
}));
}
let mut results = Vec::with_capacity(handles.len());
for handle in handles {
match handle.await {
Ok(check) => results.push(check),
Err(e) => results.push(UrlCheck {
url: "unknown".into(),
status: CheckStatus::Error,
detail: Some(format!("task panicked: {e}")),
is_thumbnail: false,
}),
}
}
results
}
/// Check a URL with retry logic for transient failures.
async fn check_url_with_retry(ctx: &VerifyContext, url: &str, is_thumbnail: bool) -> UrlCheck {
let mut last_result = check_url(ctx, url, is_thumbnail).await;
for attempt in 0..ctx.max_retries {
if !is_retryable(&last_result) {
break;
}
// Random exponential backoff to avoid thundering herd
// delay = base * 2^attempt + random_jitter
let base_delay = RETRY_BASE_DELAY_MS * (1 << attempt.min(10)); // Cap exponent at 2^10
let jitter = fastrand::u64(0..=base_delay);
let delay = Duration::from_millis(base_delay + jitter);
tokio::time::sleep(delay).await;
last_result = check_url(ctx, url, is_thumbnail).await;
}
last_result
}
/// Whether a check result should be retried.
fn is_retryable(check: &UrlCheck) -> bool {
match check.status {
CheckStatus::Ok
| CheckStatus::RateLimited
| CheckStatus::Suspicious
| CheckStatus::Error => false,
CheckStatus::Warn => {
// Retry timeouts, retryable HTTP status codes, and connection errors
if let Some(ref detail) = check.detail {
if detail == "timeout" {
return true;
}
for code in &RETRYABLE_STATUS {
if detail.contains(&code.to_string()) {
return true;
}
}
detail.contains("connection")
|| detail.contains("reset")
|| detail.contains("refused")
} else {
false
}
}
}
}
async fn check_url(ctx: &VerifyContext, url: &str, is_thumbnail: bool) -> UrlCheck {
let domain = extract_domain(url);
// Skip HEAD if this domain is known to reject it
if ctx.head_rejected_domains.contains(&domain) {
return check_url_get(&ctx.client, url, is_thumbnail).await;
}
// Try HEAD first
match ctx.client.head(url).send().await {
Ok(resp) => {
let status = resp.status();
// If HEAD returns 405, learn and fall back to GET
if status == reqwest::StatusCode::METHOD_NOT_ALLOWED {
ctx.head_rejected_domains.insert(domain);
return check_url_get(&ctx.client, url, is_thumbnail).await;
}
// If HEAD returns a client error (4xx excl. 429), fall back to GET.
// Many servers don't implement HEAD correctly and return 404/403
// for HEAD but 200 for GET.
if status.is_client_error() && status.as_u16() != 429 {
let get_result = check_url_get(&ctx.client, url, is_thumbnail).await;
// If GET succeeds, learn this domain rejects HEAD
if get_result.status == CheckStatus::Ok {
ctx.head_rejected_domains.insert(domain);
}
return get_result;
}
evaluate_response(url, status, resp.headers(), is_thumbnail)
}
Err(e) => {
// Timeouts and connection errors are transient — warn, don't fail
UrlCheck {
url: url.to_string(),
status: CheckStatus::Warn,
detail: Some(if e.is_timeout() {
"timeout".into()
} else {
format!("{e}")
}),
is_thumbnail,
}
}
}
}
async fn check_url_get(client: &reqwest::Client, url: &str, is_thumbnail: bool) -> UrlCheck {
match client.get(url).send().await {
Ok(resp) => evaluate_response(url, resp.status(), resp.headers(), is_thumbnail),
Err(e) => {
// Timeouts and connection errors are transient — warn, don't fail
UrlCheck {
url: url.to_string(),
status: CheckStatus::Warn,
detail: Some(if e.is_timeout() {
"timeout".into()
} else {
format!("{e}")
}),
is_thumbnail,
}
}
}
}
fn evaluate_response(
url: &str,
status: reqwest::StatusCode,
headers: &reqwest::header::HeaderMap,
is_thumbnail: bool,
) -> UrlCheck {
if status.is_success() {
// Check thumbnail content-type
if is_thumbnail && let Some(ct) = headers.get(reqwest::header::CONTENT_TYPE) {
let ct_str = ct.to_str().unwrap_or("");
if !ct_str.starts_with("image/") {
return UrlCheck {
url: url.to_string(),
status: CheckStatus::Error,
detail: Some(format!("expected content-type image/*, got {ct_str}")),
is_thumbnail,
};
}
}
UrlCheck {
url: url.to_string(),
status: CheckStatus::Ok,
detail: None,
is_thumbnail,
}
} else if status.is_redirection() {
// Redirect not followed (should be handled by client policy)
UrlCheck {
url: url.to_string(),
status: CheckStatus::Warn,
detail: Some(format!("HTTP {status}")),
is_thumbnail,
}
} else if status.as_u16() == 429 {
// Rate limited — URL is likely fine, we just hit the limit
UrlCheck {
url: url.to_string(),
status: CheckStatus::RateLimited,
detail: Some(format!("HTTP {status}")),
is_thumbnail,
}
} else if matches!(status.as_u16(), 400 | 401 | 403 | 406) {
// Suspicious — likely bot detection or WAF blocking automated requests
UrlCheck {
url: url.to_string(),
status: CheckStatus::Suspicious,
detail: Some(format!("HTTP {status}")),
is_thumbnail,
}
} else if status.is_server_error() {
// Server errors (5xx) are transient — not fixable by us
UrlCheck {
url: url.to_string(),
status: CheckStatus::Warn,
detail: Some(format!("HTTP {status}")),
is_thumbnail,
}
} else {
// 404 and other client errors — genuinely broken, actionable
UrlCheck {
url: url.to_string(),
status: CheckStatus::Error,
detail: Some(format!("HTTP {status}")),
is_thumbnail,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn collect_urls_deduplicates() {
let sources = vec![
crate::parser::SourceEntry::Url("https://a.com".into()),
crate::parser::SourceEntry::Url("https://b.com".into()),
];
let entities = vec![Entity {
name: "Test".into(),
label: crate::entity::Label::Person,
fields: vec![(
"urls".into(),
FieldValue::List(vec!["https://a.com".into(), "https://c.com".into()]),
)],
id: None,
line: 1,
tags: Vec::new(),
slug: None,
}];
let mut errors = Vec::new();
let urls = collect_urls(&sources, &entities, &[], &mut errors);
assert!(errors.is_empty());
// a.com deduplicated
assert_eq!(urls.len(), 3);
}
#[test]
fn collect_urls_includes_thumbnails() {
let entities = vec![Entity {
name: "Test".into(),
label: crate::entity::Label::Person,
fields: vec![(
"thumbnail".into(),
FieldValue::Single("https://img.com/photo.jpg".into()),
)],
id: None,
line: 1,
tags: Vec::new(),
slug: None,
}];
let mut errors = Vec::new();
let urls = collect_urls(&[], &entities, &[], &mut errors);
assert_eq!(urls.len(), 1);
assert!(urls[0].is_thumbnail);
}
#[test]
fn collect_urls_includes_rel_sources() {
let rels = vec![Rel {
source_name: "A".into(),
target_name: "B".into(),
rel_type: "associate_of".into(),
source_urls: vec!["https://src.com".into()],
fields: vec![],
id: None,
line: 1,
}];
let mut errors = Vec::new();
let urls = collect_urls(&[], &[], &rels, &mut errors);
assert_eq!(urls.len(), 1);
assert!(!urls[0].is_thumbnail);
}
#[test]
fn collect_urls_boundary() {
let sources: Vec<crate::parser::SourceEntry> = (0..500_001)
.map(|i| crate::parser::SourceEntry::Url(format!("https://example.com/{i}")))
.collect();
let mut errors = Vec::new();
collect_urls(&sources, &[], &[], &mut errors);
assert!(errors.iter().any(|e| e.message.contains("too many URLs")));
}
#[test]
fn evaluate_success() {
let check = evaluate_response(
"https://example.com",
reqwest::StatusCode::OK,
&reqwest::header::HeaderMap::new(),
false,
);
assert_eq!(check.status, CheckStatus::Ok);
}
#[test]
fn evaluate_not_found() {
let check = evaluate_response(
"https://example.com",
reqwest::StatusCode::NOT_FOUND,
&reqwest::header::HeaderMap::new(),
false,
);
assert_eq!(check.status, CheckStatus::Error);
}
#[test]
fn evaluate_rate_limited() {
let check = evaluate_response(
"https://example.com",
reqwest::StatusCode::TOO_MANY_REQUESTS,
&reqwest::header::HeaderMap::new(),
false,
);
assert_eq!(check.status, CheckStatus::RateLimited);
}
#[test]
fn evaluate_forbidden_suspicious() {
let check = evaluate_response(
"https://example.com",
reqwest::StatusCode::FORBIDDEN,
&reqwest::header::HeaderMap::new(),
false,
);
assert_eq!(check.status, CheckStatus::Suspicious);
}
#[test]
fn evaluate_unauthorized_suspicious() {
let check = evaluate_response(
"https://example.com",
reqwest::StatusCode::UNAUTHORIZED,
&reqwest::header::HeaderMap::new(),
false,
);
assert_eq!(check.status, CheckStatus::Suspicious);
}
#[test]
fn evaluate_bad_request_suspicious() {
let check = evaluate_response(
"https://example.com",
reqwest::StatusCode::BAD_REQUEST,
&reqwest::header::HeaderMap::new(),
false,
);
assert_eq!(check.status, CheckStatus::Suspicious);
}
#[test]
fn evaluate_not_acceptable_suspicious() {
let check = evaluate_response(
"https://example.com",
reqwest::StatusCode::NOT_ACCEPTABLE,
&reqwest::header::HeaderMap::new(),
false,
);
assert_eq!(check.status, CheckStatus::Suspicious);
}
#[test]
fn evaluate_internal_server_error_warn() {
let check = evaluate_response(
"https://example.com",
reqwest::StatusCode::INTERNAL_SERVER_ERROR,
&reqwest::header::HeaderMap::new(),
false,
);
assert_eq!(check.status, CheckStatus::Warn);
}
#[test]
fn evaluate_thumbnail_wrong_content_type() {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::CONTENT_TYPE,
"text/html".parse().unwrap_or_else(|_| unreachable!()),
);
let check = evaluate_response(
"https://example.com/img.jpg",
reqwest::StatusCode::OK,
&headers,
true,
);
assert_eq!(check.status, CheckStatus::Error);
assert!(check.detail.as_deref().unwrap_or("").contains("image/*"));
}
#[test]
fn evaluate_thumbnail_correct_content_type() {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::CONTENT_TYPE,
"image/jpeg".parse().unwrap_or_else(|_| unreachable!()),
);
let check = evaluate_response(
"https://example.com/img.jpg",
reqwest::StatusCode::OK,
&headers,
true,
);
assert_eq!(check.status, CheckStatus::Ok);
}
#[test]
fn extract_domain_works() {
assert_eq!(
extract_domain("https://www.example.com/page"),
"www.example.com"
);
assert_eq!(extract_domain("https://Example.COM/page"), "example.com");
assert_eq!(extract_domain("not-a-url"), "");
}
#[test]
fn is_retryable_timeout() {
let check = UrlCheck {
url: "https://example.com".into(),
status: CheckStatus::Warn,
detail: Some("timeout".into()),
is_thumbnail: false,
};
assert!(is_retryable(&check));
}
#[test]
fn is_retryable_429_not_retryable() {
let check = UrlCheck {
url: "https://example.com".into(),
status: CheckStatus::RateLimited,
detail: Some("HTTP 429 Too Many Requests".into()),
is_thumbnail: false,
};
assert!(!is_retryable(&check));
}
#[test]
fn is_retryable_503() {
let check = UrlCheck {
url: "https://example.com".into(),
status: CheckStatus::Warn,
detail: Some("HTTP 503 Service Unavailable".into()),
is_thumbnail: false,
};
assert!(is_retryable(&check));
}
#[test]
fn is_retryable_404_not_retryable() {
let check = UrlCheck {
url: "https://example.com".into(),
status: CheckStatus::Error,
detail: Some("HTTP 404 Not Found".into()),
is_thumbnail: false,
};
assert!(!is_retryable(&check));
}
#[test]
fn is_retryable_ok_not_retryable() {
let check = UrlCheck {
url: "https://example.com".into(),
status: CheckStatus::Ok,
detail: None,
is_thumbnail: false,
};
assert!(!is_retryable(&check));
}
#[tokio::test]
async fn verify_urls_with_mock_server_ok() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("HEAD", "/page")
.with_status(200)
.create_async()
.await;
let urls = vec![UrlEntry {
url: format!("{}/page", server.url()),
is_thumbnail: false,
}];
let results = verify_urls(urls, 4, 5, 2, 0).await;
assert_eq!(results.len(), 1);
assert_eq!(results[0].status, CheckStatus::Ok);
mock.assert_async().await;
}
#[tokio::test]
async fn verify_urls_with_mock_server_404() {
let mut server = mockito::Server::new_async().await;
let head_mock = server
.mock("HEAD", "/missing")
.with_status(404)
.create_async()
.await;
// HEAD 404 triggers GET fallback; GET also returns 404 → Error
let get_mock = server
.mock("GET", "/missing")
.with_status(404)
.create_async()
.await;
let urls = vec![UrlEntry {
url: format!("{}/missing", server.url()),
is_thumbnail: false,
}];
let results = verify_urls(urls, 4, 5, 2, 0).await;
assert_eq!(results.len(), 1);
assert_eq!(results[0].status, CheckStatus::Error);
assert!(results[0].detail.as_deref().unwrap_or("").contains("404"));
head_mock.assert_async().await;
get_mock.assert_async().await;
}
#[tokio::test]
async fn verify_urls_head_404_falls_back_to_get() {
let mut server = mockito::Server::new_async().await;
// Some servers return 404 for HEAD but 200 for GET
let head_mock = server
.mock("HEAD", "/head-broken")
.with_status(404)
.create_async()
.await;
let get_mock = server
.mock("GET", "/head-broken")
.with_status(200)
.create_async()
.await;
let urls = vec![UrlEntry {
url: format!("{}/head-broken", server.url()),
is_thumbnail: false,
}];
let results = verify_urls(urls, 4, 5, 2, 0).await;
assert_eq!(results.len(), 1);
assert_eq!(results[0].status, CheckStatus::Ok);
head_mock.assert_async().await;
get_mock.assert_async().await;
}
#[tokio::test]
async fn verify_urls_head_405_falls_back_to_get() {
let mut server = mockito::Server::new_async().await;
let head_mock = server
.mock("HEAD", "/no-head")
.with_status(405)
.create_async()
.await;
let get_mock = server
.mock("GET", "/no-head")
.with_status(200)
.create_async()
.await;
let urls = vec![UrlEntry {
url: format!("{}/no-head", server.url()),
is_thumbnail: false,
}];
let results = verify_urls(urls, 4, 5, 2, 0).await;
assert_eq!(results.len(), 1);
assert_eq!(results[0].status, CheckStatus::Ok);
head_mock.assert_async().await;
get_mock.assert_async().await;
}
#[tokio::test]
async fn verify_urls_head_405_learns_domain() {
let mut server = mockito::Server::new_async().await;
// First URL: HEAD 405, then GET 200
let head_mock = server
.mock("HEAD", "/first")
.with_status(405)
.expect(1)
.create_async()
.await;
let get_first = server
.mock("GET", "/first")
.with_status(200)
.create_async()
.await;
// Second URL on same domain: should skip HEAD entirely
let get_second = server
.mock("GET", "/second")
.with_status(200)
.create_async()
.await;
let urls = vec![
UrlEntry {
url: format!("{}/first", server.url()),
is_thumbnail: false,
},
UrlEntry {
url: format!("{}/second", server.url()),
is_thumbnail: false,
},
];
// Use concurrency=1 to ensure sequential processing so learning works
let results = verify_urls(urls, 1, 5, 1, 0).await;
assert_eq!(results.len(), 2);
assert_eq!(results[0].status, CheckStatus::Ok);
assert_eq!(results[1].status, CheckStatus::Ok);
head_mock.assert_async().await;
get_first.assert_async().await;
get_second.assert_async().await;
}
#[tokio::test]
async fn verify_urls_thumbnail_content_type_check() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("HEAD", "/img.jpg")
.with_status(200)
.with_header("content-type", "image/jpeg")
.create_async()
.await;
let urls = vec![UrlEntry {
url: format!("{}/img.jpg", server.url()),
is_thumbnail: true,
}];
let results = verify_urls(urls, 4, 5, 2, 0).await;
assert_eq!(results.len(), 1);
assert_eq!(results[0].status, CheckStatus::Ok);
mock.assert_async().await;
}
#[tokio::test]
async fn verify_urls_thumbnail_wrong_content_type() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("HEAD", "/not-image")
.with_status(200)
.with_header("content-type", "text/html")
.create_async()
.await;
let urls = vec![UrlEntry {
url: format!("{}/not-image", server.url()),
is_thumbnail: true,
}];
let results = verify_urls(urls, 4, 5, 2, 0).await;
assert_eq!(results.len(), 1);
assert_eq!(results[0].status, CheckStatus::Error);
assert!(
results[0]
.detail
.as_deref()
.unwrap_or("")
.contains("image/*")
);
mock.assert_async().await;
}
#[tokio::test]
async fn verify_urls_retries_503() {
let mut server = mockito::Server::new_async().await;
// First attempt: 503, second attempt: 200
let fail_mock = server
.mock("HEAD", "/retry")
.with_status(503)
.expect(1)
.create_async()
.await;
let ok_mock = server
.mock("HEAD", "/retry")
.with_status(200)
.expect(1)
.create_async()
.await;
let urls = vec![UrlEntry {
url: format!("{}/retry", server.url()),
is_thumbnail: false,
}];
let results = verify_urls(urls, 4, 5, 2, 2).await;
assert_eq!(results.len(), 1);
assert_eq!(results[0].status, CheckStatus::Ok);
fail_mock.assert_async().await;
ok_mock.assert_async().await;
}
#[tokio::test]
async fn verify_urls_429_rate_limited() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("HEAD", "/limited")
.with_status(429)
.create_async()
.await;
let urls = vec![UrlEntry {
url: format!("{}/limited", server.url()),
is_thumbnail: false,
}];
let results = verify_urls(urls, 4, 5, 2, 0).await;
assert_eq!(results.len(), 1);
assert_eq!(results[0].status, CheckStatus::RateLimited);
mock.assert_async().await;
}
#[tokio::test]
async fn verify_urls_403_suspicious() {
let mut server = mockito::Server::new_async().await;
let head_mock = server
.mock("HEAD", "/forbidden")
.with_status(403)
.create_async()
.await;
// HEAD 403 triggers GET fallback; GET also returns 403 → Suspicious
let get_mock = server
.mock("GET", "/forbidden")
.with_status(403)
.create_async()
.await;
let urls = vec![UrlEntry {
url: format!("{}/forbidden", server.url()),
is_thumbnail: false,
}];
let results = verify_urls(urls, 4, 5, 2, 0).await;
assert_eq!(results.len(), 1);
assert_eq!(results[0].status, CheckStatus::Suspicious);
head_mock.assert_async().await;
get_mock.assert_async().await;
}
}