Packages
rbt_weave
0.2.64
0.2.67
0.2.66
0.2.65
0.2.64
0.2.63
0.2.62
0.2.61
0.2.60
0.2.59
0.2.58
0.2.57
0.2.56
0.2.55
0.2.54
0.2.53
0.2.52
0.2.51
0.2.50
0.2.49
0.2.48
0.2.47
0.2.46
0.2.45
0.2.44
0.2.43
0.2.42
0.2.41
0.2.40
0.2.39
0.2.38
0.2.37
0.2.36
0.2.35
0.2.34
0.2.33
0.2.32
0.2.31
0.2.30
0.2.29
0.2.28
0.2.27
0.2.26
0.2.25
0.2.24
0.2.23
0.2.22
0.2.21
0.2.20
0.2.19
0.2.18
0.2.17
0.2.16
0.2.15
0.2.14
0.2.13
0.2.12
0.2.11
0.2.10
0.2.9
0.2.8
0.2.7
0.2.6
0.2.5
0.2.4
0.2.3
0.2.2
0.2.1
0.2.0
0.1.0
Graph conflict detection and image processing NIFs for OSINT knowledge graphs, powered by Rust.
Current section
Files
Jump to
Current section
Files
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;
}
}