use super::*;
use std::sync::Arc;
use std::time::{Duration, Instant, SystemTime};
use bytes::Bytes;
use http::header::{CACHE_CONTROL, ETAG, EXPIRES, LAST_MODIFIED};
use http::{HeaderMap, Method, StatusCode, Uri};
#[path = "tests/expires_freshness.rs"]
mod expires_freshness;
#[path = "tests/helpers.rs"]
mod helpers;
#[path = "tests/store_behavior.rs"]
mod store_behavior;
#[path = "tests/vary.rs"]
mod vary;
#[test]
fn test_cache_store_and_fresh_lookup() {
let cache = HttpCache::new();
let uri: Uri = "http://example.com/test".parse().unwrap();
let mut headers = HeaderMap::new();
headers.insert(CACHE_CONTROL, "max-age=3600".parse().unwrap());
let body = Bytes::from("hello");
cache.store(
&Method::GET,
&uri,
StatusCode::OK,
&headers,
&body,
&HeaderMap::new(),
);
match cache.lookup(&Method::GET, &uri, &HeaderMap::new()) {
CacheLookup::Fresh(resp) => {
assert_eq!(resp.status, StatusCode::OK);
assert_eq!(resp.body, Bytes::from("hello"));
}
_ => panic!("expected fresh cache hit"),
}
}
#[test]
fn test_cache_no_store() {
let cache = HttpCache::new();
let uri: Uri = "http://example.com/secret".parse().unwrap();
let mut headers = HeaderMap::new();
headers.insert(CACHE_CONTROL, "no-store".parse().unwrap());
let body = Bytes::from("secret");
cache.store(
&Method::GET,
&uri,
StatusCode::OK,
&headers,
&body,
&HeaderMap::new(),
);
assert!(cache.is_empty());
}
#[test]
fn test_cache_stale_with_etag() {
let cache = HttpCache::new();
let uri: Uri = "http://example.com/data".parse().unwrap();
let mut headers = HeaderMap::new();
headers.insert(CACHE_CONTROL, "max-age=0".parse().unwrap());
headers.insert(ETAG, "\"abc123\"".parse().unwrap());
let body = Bytes::from("data");
cache.store(
&Method::GET,
&uri,
StatusCode::OK,
&headers,
&body,
&HeaderMap::new(),
);
match cache.lookup(&Method::GET, &uri, &HeaderMap::new()) {
CacheLookup::Stale { validators, .. } => {
assert_eq!(validators.etag.as_deref(), Some("\"abc123\""));
}
_ => panic!("expected stale cache hit with validators"),
}
}
#[test]
fn test_cache_post_not_cached() {
let cache = HttpCache::new();
let uri: Uri = "http://example.com/api".parse().unwrap();
let mut headers = HeaderMap::new();
headers.insert(CACHE_CONTROL, "max-age=3600".parse().unwrap());
let body = Bytes::from("result");
cache.store(
&Method::POST,
&uri,
StatusCode::OK,
&headers,
&body,
&HeaderMap::new(),
);
assert!(cache.is_empty());
}
#[test]
fn test_cache_invalidation_on_post() {
let cache = HttpCache::new();
let uri: Uri = "http://example.com/resource".parse().unwrap();
let mut headers = HeaderMap::new();
headers.insert(CACHE_CONTROL, "max-age=3600".parse().unwrap());
let body = Bytes::from("data");
cache.store(
&Method::GET,
&uri,
StatusCode::OK,
&headers,
&body,
&HeaderMap::new(),
);
assert_eq!(cache.len(), 1);
cache.invalidate(&Method::POST, &uri);
assert!(cache.is_empty());
}
#[test]
fn test_max_entries_eviction() {
let cache = HttpCache::with_config(CacheConfig { max_entries: 2 });
let mut headers = HeaderMap::new();
headers.insert(CACHE_CONTROL, "max-age=3600".parse().unwrap());
for i in 0..3 {
let uri: Uri = format!("http://example.com/{i}").parse().unwrap();
cache.store(
&Method::GET,
&uri,
StatusCode::OK,
&headers,
&Bytes::from("x"),
&HeaderMap::new(),
);
}
assert_eq!(cache.len(), 2);
}
#[test]
fn test_cache_clear() {
let cache = HttpCache::new();
let uri: Uri = "http://example.com/".parse().unwrap();
let mut headers = HeaderMap::new();
headers.insert(CACHE_CONTROL, "max-age=3600".parse().unwrap());
cache.store(
&Method::GET,
&uri,
StatusCode::OK,
&headers,
&Bytes::from("x"),
&HeaderMap::new(),
);
assert!(!cache.is_empty());
cache.clear();
assert!(cache.is_empty());
}
#[test]
fn test_cache_config_default() {
let config = CacheConfig::default();
assert_eq!(config.max_entries, 256);
}
#[test]
fn test_cache_private_not_stored() {
let cache = HttpCache::new();
let uri: Uri = "http://example.com/private".parse().unwrap();
let mut headers = HeaderMap::new();
headers.insert(CACHE_CONTROL, "private, max-age=60".parse().unwrap());
cache.store(
&Method::GET,
&uri,
StatusCode::OK,
&headers,
&Bytes::from("x"),
&HeaderMap::new(),
);
assert!(cache.is_empty());
}
#[test]
fn test_cache_must_revalidate() {
let cache = HttpCache::new();
let uri: Uri = "http://example.com/reval".parse().unwrap();
let mut headers = HeaderMap::new();
headers.insert(
CACHE_CONTROL,
"max-age=3600, must-revalidate".parse().unwrap(),
);
headers.insert(ETAG, "\"v1\"".parse().unwrap());
cache.store(
&Method::GET,
&uri,
StatusCode::OK,
&headers,
&Bytes::from("x"),
&HeaderMap::new(),
);
match cache.lookup(&Method::GET, &uri, &HeaderMap::new()) {
CacheLookup::Stale { validators, .. } => {
assert_eq!(validators.etag.as_deref(), Some("\"v1\""));
}
_ => panic!("expected stale due to must-revalidate"),
}
}
#[test]
fn test_cache_no_cache_forces_revalidation() {
let cache = HttpCache::new();
let uri: Uri = "http://example.com/nc".parse().unwrap();
let mut headers = HeaderMap::new();
headers.insert(CACHE_CONTROL, "no-cache".parse().unwrap());
headers.insert(ETAG, "\"v2\"".parse().unwrap());
cache.store(
&Method::GET,
&uri,
StatusCode::OK,
&headers,
&Bytes::from("x"),
&HeaderMap::new(),
);
match cache.lookup(&Method::GET, &uri, &HeaderMap::new()) {
CacheLookup::Stale { .. } => {}
_ => panic!("expected stale due to no-cache"),
}
}
#[test]
fn test_cache_immutable() {
let cache = HttpCache::new();
let uri: Uri = "http://example.com/immut".parse().unwrap();
let mut headers = HeaderMap::new();
headers.insert(CACHE_CONTROL, "max-age=3600, immutable".parse().unwrap());
cache.store(
&Method::GET,
&uri,
StatusCode::OK,
&headers,
&Bytes::from("x"),
&HeaderMap::new(),
);
match cache.lookup(&Method::GET, &uri, &HeaderMap::new()) {
CacheLookup::Fresh(_) => {}
_ => panic!("expected fresh for immutable entry"),
}
}
#[test]
fn test_cache_stale_while_revalidate() {
let cache = HttpCache::new();
let uri: Uri = "http://example.com/swr".parse().unwrap();
let mut headers = HeaderMap::new();
headers.insert(
CACHE_CONTROL,
"max-age=0, stale-while-revalidate=3600".parse().unwrap(),
);
cache.store(
&Method::GET,
&uri,
StatusCode::OK,
&headers,
&Bytes::from("stale-ok"),
&HeaderMap::new(),
);
match cache.lookup(&Method::GET, &uri, &HeaderMap::new()) {
CacheLookup::Fresh(resp) => {
assert_eq!(resp.body, Bytes::from("stale-ok"));
}
_ => panic!("expected fresh via stale-while-revalidate"),
}
}
#[test]
fn test_cache_stale_if_error_propagated() {
let cache = HttpCache::new();
let uri: Uri = "http://example.com/sie".parse().unwrap();
let mut headers = HeaderMap::new();
headers.insert(
CACHE_CONTROL,
"max-age=0, stale-if-error=600".parse().unwrap(),
);
headers.insert(ETAG, "\"sie\"".parse().unwrap());
cache.store(
&Method::GET,
&uri,
StatusCode::OK,
&headers,
&Bytes::from("x"),
&HeaderMap::new(),
);
match cache.lookup(&Method::GET, &uri, &HeaderMap::new()) {
CacheLookup::Stale { stale_if_error, .. } => {
assert_eq!(stale_if_error, Some(Duration::from_secs(600)));
}
_ => panic!("expected stale with stale_if_error"),
}
}
#[test]
fn test_cache_head_method() {
let cache = HttpCache::new();
let uri: Uri = "http://example.com/head".parse().unwrap();
let mut headers = HeaderMap::new();
headers.insert(CACHE_CONTROL, "max-age=3600".parse().unwrap());
cache.store(
&Method::HEAD,
&uri,
StatusCode::OK,
&headers,
&Bytes::new(),
&HeaderMap::new(),
);
assert_eq!(cache.len(), 1);
}
#[test]
fn test_cache_invalidation_on_delete() {
let cache = HttpCache::new();
let uri: Uri = "http://example.com/resource".parse().unwrap();
let mut headers = HeaderMap::new();
headers.insert(CACHE_CONTROL, "max-age=3600".parse().unwrap());
cache.store(
&Method::GET,
&uri,
StatusCode::OK,
&headers,
&Bytes::from("x"),
&HeaderMap::new(),
);
assert_eq!(cache.len(), 1);
cache.invalidate(&Method::DELETE, &uri);
assert!(cache.is_empty());
}
#[test]
fn test_cache_get_does_not_invalidate() {
let cache = HttpCache::new();
let uri: Uri = "http://example.com/safe".parse().unwrap();
let mut headers = HeaderMap::new();
headers.insert(CACHE_CONTROL, "max-age=3600".parse().unwrap());
cache.store(
&Method::GET,
&uri,
StatusCode::OK,
&headers,
&Bytes::from("x"),
&HeaderMap::new(),
);
cache.invalidate(&Method::GET, &uri);
assert_eq!(cache.len(), 1);
}
#[test]
fn test_cache_s_maxage() {
let cache = HttpCache::new();
let uri: Uri = "http://example.com/smaxage".parse().unwrap();
let mut headers = HeaderMap::new();
headers.insert(CACHE_CONTROL, "s-maxage=3600".parse().unwrap());
cache.store(
&Method::GET,
&uri,
StatusCode::OK,
&headers,
&Bytes::from("x"),
&HeaderMap::new(),
);
match cache.lookup(&Method::GET, &uri, &HeaderMap::new()) {
CacheLookup::Fresh(_) => {}
_ => panic!("expected fresh from s-maxage"),
}
}
#[test]
fn test_validators_apply_to_request() {
let validators = Validators {
etag: Some("\"abc\"".to_string()),
last_modified: Some("Sun, 06 Nov 1994 08:49:37 GMT".to_string()),
};
let mut headers = HeaderMap::new();
validators.apply_to_request(&mut headers);
assert!(headers.contains_key(IF_NONE_MATCH));
assert!(headers.contains_key(IF_MODIFIED_SINCE));
}
#[test]
fn test_cache_lookup_post_is_miss() {
let cache = HttpCache::new();
let uri: Uri = "http://example.com/".parse().unwrap();
match cache.lookup(&Method::POST, &uri, &HeaderMap::new()) {
CacheLookup::Miss => {}
_ => panic!("expected miss for POST"),
}
}
#[test]
fn test_cache_debug() {
let cache = HttpCache::new();
let dbg = format!("{cache:?}");
assert!(dbg.contains("HttpCache"));
}
#[test]
fn test_cache_default() {
let cache: HttpCache = Default::default();
assert!(cache.is_empty());
}
#[test]
fn test_cache_expires_based_freshness() {
let cache = HttpCache::new();
let uri: Uri = "http://example.com/expires".parse().unwrap();
let mut headers = HeaderMap::new();
headers.insert(EXPIRES, "Thu, 01 Jan 2099 00:00:00 GMT".parse().unwrap());
let body = Bytes::from("expires-fresh");
cache.store(
&Method::GET,
&uri,
StatusCode::OK,
&headers,
&body,
&HeaderMap::new(),
);
match cache.lookup(&Method::GET, &uri, &HeaderMap::new()) {
CacheLookup::Fresh(resp) => {
assert_eq!(resp.body, Bytes::from("expires-fresh"));
}
_ => panic!("expected fresh from Expires header"),
}
}
#[test]
fn test_cache_expires_based_staleness_with_etag() {
let cache = HttpCache::new();
let uri: Uri = "http://example.com/stale-expires".parse().unwrap();
let mut headers = HeaderMap::new();
headers.insert(EXPIRES, "Thu, 01 Jan 2020 00:00:00 GMT".parse().unwrap());
headers.insert(ETAG, "\"exp-v1\"".parse().unwrap());
let body = Bytes::from("expired");
cache.store(
&Method::GET,
&uri,
StatusCode::OK,
&headers,
&body,
&HeaderMap::new(),
);
match cache.lookup(&Method::GET, &uri, &HeaderMap::new()) {
CacheLookup::Stale { validators, .. } => {
assert_eq!(validators.etag.as_deref(), Some("\"exp-v1\""));
}
_ => panic!("expected stale due to expired Expires header"),
}
}
#[test]
fn test_cache_stale_without_validators_is_miss() {
let cache = HttpCache::new();
let uri: Uri = "http://example.com/no-validators".parse().unwrap();
let mut headers = HeaderMap::new();
headers.insert(CACHE_CONTROL, "max-age=0".parse().unwrap());
let body = Bytes::from("no validators");
cache.store(
&Method::GET,
&uri,
StatusCode::OK,
&headers,
&body,
&HeaderMap::new(),
);
match cache.lookup(&Method::GET, &uri, &HeaderMap::new()) {
CacheLookup::Miss => {}
_ => panic!("expected miss for stale entry without validators"),
}
}
#[test]
fn cache_max_age_zero_without_validators_is_miss() {
let cache = HttpCache::new();
let uri: Uri = "http://example.com/max-age-zero-no-validators"
.parse()
.unwrap();
let mut headers = HeaderMap::new();
headers.insert(CACHE_CONTROL, "max-age=0".parse().unwrap());
cache.store(
&Method::GET,
&uri,
StatusCode::OK,
&headers,
&Bytes::from("should not be retrievable"),
&HeaderMap::new(),
);
match cache.lookup(&Method::GET, &uri, &HeaderMap::new()) {
CacheLookup::Miss => {}
CacheLookup::Fresh(_) => panic!("expected Miss, got Fresh"),
CacheLookup::Stale { .. } => panic!("expected Miss, got Stale"),
}
}
#[test]
fn test_cache_immutable_with_must_revalidate() {
let cache = HttpCache::new();
let uri: Uri = "http://example.com/immut-mr".parse().unwrap();
let mut headers = HeaderMap::new();
headers.insert(
CACHE_CONTROL,
"max-age=3600, immutable, must-revalidate".parse().unwrap(),
);
let body = Bytes::from("immut");
cache.store(
&Method::GET,
&uri,
StatusCode::OK,
&headers,
&body,
&HeaderMap::new(),
);
match cache.lookup(&Method::GET, &uri, &HeaderMap::new()) {
CacheLookup::Fresh(resp) => {
assert_eq!(resp.body, Bytes::from("immut"));
}
_ => panic!("expected fresh for immutable+must_revalidate entry"),
}
}
#[test]
fn test_httpdate_parse_november() {
let result = httpdate_parse("Sun, 06 Nov 1994 08:49:37 GMT");
assert!(result.is_some());
}
#[test]
fn test_httpdate_parse_december() {
let result = httpdate_parse("Sun, 25 Dec 2022 12:00:00 GMT");
assert!(result.is_some());
}
#[test]
fn test_httpdate_parse_all_months() {
let months = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
for m in &months {
let date = format!("Mon, 15 {m} 2023 10:30:00 GMT");
assert!(httpdate_parse(&date).is_some(), "month {m} should parse");
}
}
#[test]
fn test_httpdate_leap_year() {
let result = httpdate_parse("Mon, 01 Mar 2024 00:00:00 GMT");
assert!(result.is_some());
}
#[test]
fn test_cache_config_debug() {
let config = CacheConfig::default();
let dbg = format!("{config:?}");
assert!(dbg.contains("CacheConfig"));
}
#[test]
fn test_cached_response_into_http_response() {
let cached = CachedResponse {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: Bytes::from("resp"),
age: Duration::from_secs(42),
};
let resp = cached.into_http_response();
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(resp.headers().get(AGE).unwrap().to_str().unwrap(), "42");
}
#[test]
fn test_validators_apply_only_etag() {
let validators = Validators {
etag: Some("\"only-etag\"".to_string()),
last_modified: None,
};
let mut headers = HeaderMap::new();
validators.apply_to_request(&mut headers);
assert!(headers.contains_key(IF_NONE_MATCH));
assert!(!headers.contains_key(IF_MODIFIED_SINCE));
}
#[test]
fn test_validators_apply_only_last_modified() {
let validators = Validators {
etag: None,
last_modified: Some("Sun, 06 Nov 1994 08:49:37 GMT".to_string()),
};
let mut headers = HeaderMap::new();
validators.apply_to_request(&mut headers);
assert!(!headers.contains_key(IF_NONE_MATCH));
assert!(headers.contains_key(IF_MODIFIED_SINCE));
}
#[test]
fn test_is_response_cacheable_with_expires() {
let mut headers = HeaderMap::new();
headers.insert(EXPIRES, "Thu, 01 Jan 2099 00:00:00 GMT".parse().unwrap());
assert!(is_response_cacheable(StatusCode::OK, &headers));
}
#[test]
fn test_non_cacheable_status_not_stored() {
let cache = HttpCache::new();
let uri: Uri = "http://example.com/500".parse().unwrap();
let mut headers = HeaderMap::new();
headers.insert(CACHE_CONTROL, "max-age=3600".parse().unwrap());
cache.store(
&Method::GET,
&uri,
StatusCode::INTERNAL_SERVER_ERROR,
&headers,
&Bytes::from("err"),
&HeaderMap::new(),
);
assert!(cache.is_empty());
}
#[test]
fn test_staleness_with_expired_expires_header() {
let cache = HttpCache::new();
let uri: Uri = "http://example.com/past-expires".parse().unwrap();
let mut headers = HeaderMap::new();
headers.insert(EXPIRES, "Thu, 01 Jan 2020 00:00:00 GMT".parse().unwrap());
headers.insert(ETAG, "\"expired-v1\"".parse().unwrap());
cache.store(
&Method::GET,
&uri,
StatusCode::OK,
&headers,
&Bytes::from("old data"),
&HeaderMap::new(),
);
match cache.lookup(&Method::GET, &uri, &HeaderMap::new()) {
CacheLookup::Stale { cached, .. } => {
assert_eq!(cached.body, Bytes::from("old data"));
}
CacheLookup::Fresh(_) => panic!("past expires should not be fresh"),
CacheLookup::Miss => panic!("should find the entry (has validators)"),
}
}
#[test]
fn test_staleness_with_max_age_zero() {
let cache = HttpCache::new();
let uri: Uri = "http://example.com/immediate-stale".parse().unwrap();
let mut headers = HeaderMap::new();
headers.insert(CACHE_CONTROL, "max-age=0".parse().unwrap());
headers.insert(ETAG, "\"v1\"".parse().unwrap());
cache.store(
&Method::GET,
&uri,
StatusCode::OK,
&headers,
&Bytes::from("stale immediately"),
&HeaderMap::new(),
);
std::thread::sleep(std::time::Duration::from_millis(1));
match cache.lookup(&Method::GET, &uri, &HeaderMap::new()) {
CacheLookup::Stale { cached, .. } => {
assert_eq!(cached.body, Bytes::from("stale immediately"));
}
CacheLookup::Fresh(_) => panic!("max-age=0 should be stale immediately"),
CacheLookup::Miss => panic!("should find the entry (has etag)"),
}
}
#[test]
fn test_staleness_with_future_expires_is_fresh() {
let cache = HttpCache::new();
let uri: Uri = "http://example.com/future-expires".parse().unwrap();
let mut headers = HeaderMap::new();
headers.insert(EXPIRES, "Thu, 01 Jan 2099 00:00:00 GMT".parse().unwrap());
cache.store(
&Method::GET,
&uri,
StatusCode::OK,
&headers,
&Bytes::from("future data"),
&HeaderMap::new(),
);
match cache.lookup(&Method::GET, &uri, &HeaderMap::new()) {
CacheLookup::Fresh(entry) => {
assert_eq!(entry.body, Bytes::from("future data"));
}
_ => panic!("future expires should be fresh"),
}
}
#[test]
fn test_staleness_via_expires_with_swr_serves_fresh() {
let cache = HttpCache::new();
let uri: Uri = "http://example.com/exp-swr-staleness".parse().unwrap();
let mut headers = HeaderMap::new();
headers.insert(EXPIRES, "Thu, 01 Jan 2020 00:00:00 GMT".parse().unwrap());
headers.insert(
CACHE_CONTROL,
"stale-while-revalidate=999999999".parse().unwrap(),
);
cache.store(
&Method::GET,
&uri,
StatusCode::OK,
&headers,
&Bytes::from("swr via expires"),
&HeaderMap::new(),
);
match cache.lookup(&Method::GET, &uri, &HeaderMap::new()) {
CacheLookup::Fresh(entry) => {
assert_eq!(entry.body, Bytes::from("swr via expires"));
}
_ => panic!("swr with past expires should still serve as fresh"),
}
}
#[test]
fn test_cacheable_status_codes() {
let cacheable = [200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414, 501];
for code in cacheable {
assert!(
is_cacheable_status(StatusCode::from_u16(code).unwrap()),
"status {code} should be cacheable"
);
}
let not_cacheable = [201, 202, 302, 303, 307, 400, 401, 403, 500, 502, 503];
for code in not_cacheable {
assert!(
!is_cacheable_status(StatusCode::from_u16(code).unwrap()),
"status {code} should not be cacheable"
);
}
}
#[test]
fn test_cache_last_modified_validator() {
let cache = HttpCache::new();
let uri: Uri = "http://example.com/lm".parse().unwrap();
let mut headers = HeaderMap::new();
headers.insert(CACHE_CONTROL, "max-age=0".parse().unwrap());
headers.insert(
LAST_MODIFIED,
"Sun, 06 Nov 1994 08:49:37 GMT".parse().unwrap(),
);
cache.store(
&Method::GET,
&uri,
StatusCode::OK,
&headers,
&Bytes::from("modified"),
&HeaderMap::new(),
);
match cache.lookup(&Method::GET, &uri, &HeaderMap::new()) {
CacheLookup::Stale { validators, .. } => {
assert_eq!(
validators.last_modified.as_deref(),
Some("Sun, 06 Nov 1994 08:49:37 GMT")
);
assert!(validators.etag.is_none());
}
_ => panic!("expected stale with last_modified validator"),
}
}
#[test]
fn test_parse_cache_control_non_ascii_returns_defaults() {
let mut headers = HeaderMap::new();
headers.insert(
CACHE_CONTROL,
http::HeaderValue::from_bytes(b"max-age=3600, \xff\xfe").unwrap(),
);
let directives = parse_cache_control(&headers);
assert!(directives.max_age.is_none());
assert!(!directives.no_store);
assert!(!directives.no_cache);
assert!(!directives.private);
assert!(!directives.must_revalidate);
assert!(!directives.immutable);
assert!(directives.stale_while_revalidate.is_none());
assert!(directives.stale_if_error.is_none());
}
#[test]
fn test_vary_matches_with_vary_but_no_stored_headers_returns_true() {
let entry = CacheEntry {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: Bytes::from("data"),
stored_at: Instant::now(),
max_age: Some(Duration::from_secs(3600)),
expires_at: None,
etag: None,
last_modified: None,
must_revalidate: false,
immutable: false,
stale_while_revalidate: None,
stale_if_error: None,
vary: Some(vec!["accept-encoding".to_string()]),
request_vary_headers: None, };
let request_headers = HeaderMap::new();
assert!(vary_matches(&entry, &request_headers));
}
#[test]
fn test_staleness_returns_none_when_max_age_not_exceeded() {
let entry = CacheEntry {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: Bytes::from("fresh"),
stored_at: Instant::now(), max_age: Some(Duration::from_secs(3600)),
expires_at: None,
etag: None,
last_modified: None,
must_revalidate: false,
immutable: false,
stale_while_revalidate: None,
stale_if_error: None,
vary: None,
request_vary_headers: None,
};
assert!(entry.staleness().is_none());
}
#[test]
fn test_staleness_returns_none_when_expires_in_future() {
let entry = CacheEntry {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: Bytes::from("fresh-expires"),
stored_at: Instant::now(),
max_age: None, expires_at: Some(SystemTime::now() + Duration::from_secs(3600)),
etag: None,
last_modified: None,
must_revalidate: false,
immutable: false,
stale_while_revalidate: None,
stale_if_error: None,
vary: None,
request_vary_headers: None,
};
assert!(entry.staleness().is_none());
}
#[test]
fn test_staleness_returns_none_when_no_max_age_no_expires() {
let entry = CacheEntry {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: Bytes::from("no-info"),
stored_at: Instant::now(),
max_age: None,
expires_at: None,
etag: None,
last_modified: None,
must_revalidate: false,
immutable: false,
stale_while_revalidate: None,
stale_if_error: None,
vary: None,
request_vary_headers: None,
};
assert!(entry.staleness().is_none());
}
#[test]
fn test_staleness_returns_some_when_max_age_exceeded() {
let entry = CacheEntry {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: Bytes::from("stale"),
stored_at: Instant::now() - Duration::from_secs(120), max_age: Some(Duration::from_secs(60)), expires_at: None,
etag: None,
last_modified: None,
must_revalidate: false,
immutable: false,
stale_while_revalidate: None,
stale_if_error: None,
vary: None,
request_vary_headers: None,
};
let staleness = entry.staleness();
assert!(staleness.is_some());
let staleness = staleness.unwrap();
assert!(staleness >= Duration::from_secs(59));
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn test_poisoned_lock_get_returns_none() {
let store = std::sync::Arc::new(InMemoryCacheStore::new(256));
let store_clone = store.clone();
let _ = std::thread::spawn(move || {
let _lock = store_clone.inner.lock().unwrap();
panic!("intentional panic to poison mutex");
})
.join();
let uri: Uri = "http://example.com/poisoned".parse().unwrap();
assert!(store.get(&Method::GET, &uri).is_empty());
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn test_poisoned_lock_put_is_noop() {
let store = std::sync::Arc::new(InMemoryCacheStore::new(256));
let store_clone = store.clone();
let _ = std::thread::spawn(move || {
let _lock = store_clone.inner.lock().unwrap();
panic!("intentional panic to poison mutex");
})
.join();
let uri: Uri = "http://example.com/poisoned-put".parse().unwrap();
let entry = CacheEntry {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: Bytes::from("data"),
stored_at: Instant::now(),
max_age: Some(Duration::from_secs(60)),
expires_at: None,
etag: None,
last_modified: None,
must_revalidate: false,
immutable: false,
stale_while_revalidate: None,
stale_if_error: None,
vary: None,
request_vary_headers: None,
};
store.put(&Method::GET, &uri, entry);
assert_eq!(store.len(), 0);
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn test_poisoned_lock_remove_is_noop() {
let store = std::sync::Arc::new(InMemoryCacheStore::new(256));
let store_clone = store.clone();
let _ = std::thread::spawn(move || {
let _lock = store_clone.inner.lock().unwrap();
panic!("intentional panic to poison mutex");
})
.join();
let uri: Uri = "http://example.com/poisoned-rm".parse().unwrap();
store.remove(&Method::GET, &uri);
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn test_poisoned_lock_clear_is_noop() {
let store = std::sync::Arc::new(InMemoryCacheStore::new(256));
let store_clone = store.clone();
let _ = std::thread::spawn(move || {
let _lock = store_clone.inner.lock().unwrap();
panic!("intentional panic to poison mutex");
})
.join();
store.clear();
assert!(store.is_empty());
}
#[test]
fn test_cache_no_header_returns_default_directives() {
let headers = HeaderMap::new();
let directives = parse_cache_control(&headers);
assert!(directives.max_age.is_none());
assert!(!directives.no_store);
assert!(!directives.no_cache);
assert!(!directives.private);
assert!(!directives.must_revalidate);
assert!(!directives.immutable);
}
#[test]
fn test_s_maxage_takes_precedence_over_max_age() {
let mut headers = HeaderMap::new();
headers.insert(CACHE_CONTROL, "s-maxage=3600, max-age=60".parse().unwrap());
let directives = parse_cache_control(&headers);
assert_eq!(directives.max_age, Some(Duration::from_secs(3600)));
}
#[test]
fn test_s_maxage_precedence_reverse_order() {
let mut headers = HeaderMap::new();
headers.insert(CACHE_CONTROL, "max-age=60, s-maxage=3600".parse().unwrap());
let directives = parse_cache_control(&headers);
assert_eq!(directives.max_age, Some(Duration::from_secs(3600)));
}
#[test]
fn test_no_cache_without_validators_not_stored() {
let cache = HttpCache::new();
let uri: Uri = "http://example.com/nc-no-val".parse().unwrap();
let mut headers = HeaderMap::new();
headers.insert(CACHE_CONTROL, "no-cache, max-age=3600".parse().unwrap());
cache.store(
&Method::GET,
&uri,
StatusCode::OK,
&headers,
&Bytes::from("x"),
&HeaderMap::new(),
);
assert!(
cache.is_empty(),
"no-cache without validators should not be stored"
);
}
#[test]
fn test_no_cache_with_etag_is_stored() {
let cache = HttpCache::new();
let uri: Uri = "http://example.com/nc-etag".parse().unwrap();
let mut headers = HeaderMap::new();
headers.insert(CACHE_CONTROL, "no-cache".parse().unwrap());
headers.insert(ETAG, "\"v1\"".parse().unwrap());
cache.store(
&Method::GET,
&uri,
StatusCode::OK,
&headers,
&Bytes::from("x"),
&HeaderMap::new(),
);
assert_eq!(
cache.len(),
1,
"no-cache with etag validator should be stored"
);
}