mod tests { use super::*; use http_body_util::BodyExt; fn headers(value: &str) -> HeaderMap { let mut h = HeaderMap::new(); h.insert("cache-control", value.parse().unwrap()); h } // Legacy policy; varnish-compatible behavior is tested separately. fn settings() -> Settings { Settings { enabled: true, varnish_compatible: false, ..Default::default() } } fn ticket(cache: &Cache, headers: &HeaderMap) -> Ticket { cache .ticket(1, "http", "example.test", "app", "/", headers) .unwrap() } #[test] fn ttl_safety_and_shared_directives() { let s = settings(); for control in [ "private, max-age=60", "no-store, max-age=60", "max-age=invalid", "max-age=60,max-age=90", "must-understand, max-age=60", "max-age=\"60", "max-age=60\"", "public=invalid,max-age=60", ] { assert!( plan(StatusCode::OK, &headers(control), &s, Duration::ZERO).is_none(), "{control}" ); } assert!(plan( StatusCode::OK, &headers("public,max-age=60"), &s, Duration::ZERO ) .is_some()); let mut revalidate_headers = headers("no-cache, max-age=60"); revalidate_headers.insert("etag", HeaderValue::from_static("\"validator\"")); let revalidate = plan(StatusCode::OK, &revalidate_headers, &s, Duration::ZERO) .expect("no-cache with a validator permits storage for validation"); assert!(revalidate.requires_revalidation); assert!(plan(StatusCode::OK, &headers("no-cache"), &s, Duration::ZERO).is_none()); let mut validator_only = headers("no-cache"); validator_only.insert("etag", HeaderValue::from_static("\"validator\"")); assert!(plan(StatusCode::OK, &validator_only, &s, Duration::ZERO).is_some()); let shared = plan( StatusCode::OK, &headers("public, max-age=600, s-maxage=30"), &s, Duration::ZERO, ) .unwrap(); assert_eq!(shared.ttl, 30); let mut h = headers("max-age=60"); h.insert("age", "61".parse().unwrap()); assert!(plan(StatusCode::OK, &h, &s, Duration::ZERO).is_some()); h.remove("age"); h.insert("set-cookie", "session=secret".parse().unwrap()); assert!(plan(StatusCode::OK, &h, &s, Duration::ZERO).is_none()); } #[tokio::test] async fn zero_freshness_is_retained_but_never_served_as_an_unconditional_hit() { for control in ["max-age=0", "public", "max-age=60,s-maxage=0"] { let cache = Cache::new(1); let settings = settings(); let ticket = ticket(&cache, &HeaderMap::new()); let mut response = Response::new(Body::from("zero-freshness")); *response.headers_mut() = headers(control); let plan = plan( StatusCode::OK, response.headers(), &settings, Duration::ZERO, ) .expect("valid zero freshness is distinct from a no-store response"); assert_eq!(plan.ttl, 0, "{control}"); cache .wrap(response, ticket.clone(), plan, &settings) .into_body() .collect() .await .unwrap(); assert_eq!(cache.view().entries, 1, "{control}"); assert!( cache.get(&ticket).is_none(), "zero-TTL {control} cannot be an unconditional HIT" ); } } #[test] fn request_policy_uses_conditional_cache_and_revalidation() { for name in [ "authorization", "cookie", "if-match", "if-unmodified-since", "upgrade", ] { let mut h = HeaderMap::new(); h.insert( http::HeaderName::from_bytes(name.as_bytes()).unwrap(), "value".parse().unwrap(), ); assert!( !request_cacheable(&Method::GET, &h, true, "/", false, &settings()), "{name}" ); } for name in ["if-none-match", "if-modified-since"] { let mut h = HeaderMap::new(); h.insert( http::HeaderName::from_bytes(name.as_bytes()).unwrap(), "value".parse().unwrap(), ); assert!( request_cacheable(&Method::GET, &h, true, "/", false, &settings()), "{name}" ); } for (value, reason) in [ ("no-cache", "request_no_cache"), ("max-age=0", "request_max_age"), ] { let h = headers(value); assert_eq!( request_policy(&Method::GET, &h, true, "/", false, &settings()), RequestPolicy::Revalidate(reason) ); } let min_fresh = headers("min-fresh=1"); assert_eq!( request_policy(&Method::GET, &min_fresh, true, "/", false, &settings()), RequestPolicy::Use ); let immutable = headers("immutable"); assert_eq!( request_policy(&Method::GET, &immutable, true, "/", false, &settings()), RequestPolicy::Use ); let h = headers("no-store"); assert_eq!( request_policy(&Method::GET, &h, true, "/", false, &settings()), RequestPolicy::Bypass("request_no_store") ); let mut pragma = HeaderMap::new(); pragma.insert("pragma", HeaderValue::from_static("no-cache")); assert_eq!( request_policy(&Method::GET, &pragma, true, "/", false, &settings()), RequestPolicy::Revalidate("request_pragma_no_cache") ); assert!(!request_cacheable( &Method::POST, &HeaderMap::new(), true, "/", false, &settings() )); assert!(!request_cacheable( &Method::GET, &HeaderMap::new(), false, "/", false, &settings() )); assert!(request_cacheable( &Method::GET, &HeaderMap::new(), true, "/", false, &settings() )); assert!(request_cacheable( &Method::HEAD, &HeaderMap::new(), true, "/", false, &settings() )); let mut single_range = HeaderMap::new(); single_range.insert("range", HeaderValue::from_static("bytes=0-3")); assert_eq!( request_policy( &Method::GET, &single_range, true, "/asset.bin", false, &settings() ), RequestPolicy::Use ); assert!(request_cacheable( &Method::GET, &single_range, true, "/asset.bin", false, &settings() )); single_range.insert("range", HeaderValue::from_static("bytes=0-3,7-9")); assert_eq!( request_policy( &Method::GET, &single_range, true, "/asset.bin", false, &settings() ), RequestPolicy::Bypass("request_range_unsupported") ); } #[test] fn rfc5861_windows_and_revalidation_flags_are_separate_metadata() { let s = settings(); let plan = plan( StatusCode::OK, &headers("public, max-age=60, stale-while-revalidate=30, stale-if-error=90, must-revalidate, proxy-revalidate"), &s, Duration::ZERO, ) .expect("revalidation flags must not prevent storage"); assert_eq!(plan.ttl, 60); assert_eq!(plan.stale_while_revalidate, 30); assert_eq!(plan.stale_if_error, 90); assert!(plan.must_revalidate); assert!(plan.proxy_revalidate); } #[test] fn request_max_stale_is_bounded_and_respects_revalidation_flags() { let unbounded = headers("max-stale"); let bounded = headers("max-stale=15"); assert_eq!( stale_mode(120, 60, 0, false, false, false, &unbounded), Some(false) ); assert_eq!( stale_mode(70, 60, 0, false, false, false, &bounded), Some(false) ); assert_eq!(stale_mode(80, 60, 0, false, false, false, &bounded), None); assert_eq!( stale_mode(70, 60, 30, false, false, false, &HeaderMap::new()), Some(true) ); assert_eq!( stale_mode(100, 60, 30, false, false, false, &HeaderMap::new()), None ); assert_eq!(stale_mode(70, 60, 30, true, false, false, &unbounded), None); assert_eq!(stale_mode(70, 60, 30, false, true, false, &unbounded), None); } #[test] fn cache_state_distinguishes_fresh_stale_and_expired() { let none = HeaderMap::new(); assert_eq!( cache_state(30, 60, 20, false, false, false, &none), CacheState::Fresh ); assert_eq!( cache_state(70, 60, 20, false, false, false, &none), CacheState::StaleButServable { background_revalidate: true } ); assert_eq!( cache_state(90, 60, 20, false, false, false, &none), CacheState::Expired ); } #[test] fn stale_if_error_is_only_available_inside_its_window() { let base = Revalidation { headers: HeaderMap::new(), body: Bytes::from_static(b"body"), body_allocated: 4, _transient_leases: Vec::new(), hits: 0, transformed: false, storage_key: [0; 32], resident_age_at_lookup: Duration::ZERO, skip_compression: false, reusable_without_validation: false, age: 75, ttl: 60, stale_if_error: 30, must_revalidate: false, proxy_revalidate: false, origin_etag: None, origin_last_modified: None, }; assert!(base.can_stale_if_error()); assert!(!Revalidation { age: 91, ..base.clone() } .can_stale_if_error()); assert!(!Revalidation { must_revalidate: true, ..base.clone() } .can_stale_if_error()); assert!(!Revalidation { proxy_revalidate: true, ..base } .can_stale_if_error()); } #[test] fn freshness_expiry_does_not_force_physical_eviction() { assert_eq!(retention_deadline(60, 0, 0, false), u64::MAX); assert_eq!(retention_deadline(60, 30, 0, false), u64::MAX); assert_eq!(retention_deadline(60, 0, 45, false), u64::MAX); assert_eq!(retention_deadline(60, 0, 0, true), u64::MAX); } #[test] fn request_max_age_only_revalidates_when_cached_age_exceeds_limit() { let fresh = headers("max-age=30"); assert!(request_freshness_allows(10, 60, &fresh)); assert!(!request_freshness_allows(31, 60, &fresh)); assert_eq!( request_policy(&Method::GET, &fresh, true, "/", false, &settings()), RequestPolicy::Use ); let zero = headers("max-age=0"); assert_eq!( request_policy(&Method::GET, &zero, true, "/", false, &settings()), RequestPolicy::Revalidate("request_max_age") ); let constrained_stale = headers("max-age=30, max-stale"); assert_eq!( stale_mode(70, 60, 0, false, false, false, &constrained_stale), None ); } #[test] fn duplicate_and_argument_immutable_do_not_invalidate_cache_control() { let duplicate = headers("public, max-age=60, immutable, immutable"); assert!(plan(StatusCode::OK, &duplicate, &settings(), Duration::ZERO).is_some()); let argument = headers("public, max-age=60, immutable=ignored"); assert!(plan(StatusCode::OK, &argument, &settings(), Duration::ZERO).is_some()); } #[test] fn no_transform_does_not_disable_shared_storage() { let protected = headers("public, max-age=60, no-transform"); assert!(plan(StatusCode::OK, &protected, &settings(), Duration::ZERO).is_some()); } #[test] fn transformed_plan_uses_transformed_content_length() { let s = settings(); let origin = plan( StatusCode::OK, &{ let mut h = headers("public, max-age=60"); h.insert("content-length", HeaderValue::from_static("95")); h }, &s, Duration::ZERO, ) .unwrap(); let transformed = plan( StatusCode::OK, &{ let mut h = headers("public, max-age=60"); h.insert("content-length", HeaderValue::from_static("76")); h }, &s, Duration::ZERO, ) .unwrap(); assert_eq!(origin.restrict(transformed).expected_length, Some(76)); } #[tokio::test] async fn transformed_representation_is_stored_and_reused() { let cache = Cache::new(1); let cache_settings = settings(); let request_headers = HeaderMap::new(); let ticket = ticket(&cache, &request_headers); let origin_body = br#"{ "message": "hello" }"#; let mut response = Response::new(Body::from(origin_body.as_slice())); response.headers_mut().insert( "cache-control", HeaderValue::from_static("public, max-age=60"), ); response .headers_mut() .insert("content-type", HeaderValue::from_static("application/json")); response.headers_mut().insert( "content-length", HeaderValue::from_str(&origin_body.len().to_string()).unwrap(), ); response .headers_mut() .insert("etag", HeaderValue::from_static("\"origin\"")); let origin_plan = plan( StatusCode::OK, response.headers(), &cache_settings, Duration::ZERO, ) .unwrap(); let transform_settings = crate::transform::Settings { enabled: true, json: true, ..Default::default() }; let transformed = crate::transform::apply(response, "/data.json", &transform_settings, false) .await .unwrap(); let transformed_plan = plan( StatusCode::OK, transformed.headers(), &cache_settings, Duration::ZERO, ) .unwrap(); let expected_etag = transformed.headers()["etag"].clone(); let wrapped = cache.wrap( transformed, ticket.clone(), origin_plan.restrict(transformed_plan), &cache_settings, ); let first = wrapped.into_body().collect().await.unwrap().to_bytes(); assert_eq!(&first[..], br#"{"message":"hello"}"#); let hit = cache .get(&ticket) .expect("transformed representation should be cached"); assert_eq!(hit.response.headers()["etag"], expected_etag); assert_eq!( hit.response.headers()["content-length"].to_str().unwrap(), first.len().to_string() ); let second = hit.response.into_body().collect().await.unwrap().to_bytes(); assert_eq!(second, first); } #[tokio::test] async fn every_enabled_transform_kind_can_fill_and_reuse_the_cached_representation() { let cases: [(&str, &str, &[u8], &str); 5] = [ ( "/app.js", "application/javascript", &b"const value = 1;\n"[..], "javascript", ), ( "/app.css", "text/css", &b"body { color: red; }\n"[..], "css", ), ( "/page.html", "text/html", &b"
x
"[..], "html", ), ( "/icon.svg", "image/svg+xml", &b""[..], "svg", ), ( "/data.json", "application/json", &br#"{ "a": 1 }"#[..], "json", ), ]; for (path, content_type, origin_body, kind) in cases { let cache = Cache::new(1); let cache_settings = settings(); let request_headers = HeaderMap::new(); let ticket = cache .ticket(1, "http", "example.test", "app", path, &request_headers) .unwrap(); let mut response = Response::new(Body::from(origin_body)); response.headers_mut().insert( "cache-control", HeaderValue::from_static("public, max-age=60"), ); response .headers_mut() .insert("content-type", HeaderValue::from_str(content_type).unwrap()); response.headers_mut().insert( "content-length", HeaderValue::from_str(&origin_body.len().to_string()).unwrap(), ); response .headers_mut() .insert("etag", HeaderValue::from_static("\"origin\"")); let origin_plan = plan( StatusCode::OK, response.headers(), &cache_settings, Duration::ZERO, ) .unwrap(); let mut transform_settings = crate::transform::Settings { enabled: true, javascript: false, css: false, html: false, svg: false, json: false, ..Default::default() }; match kind { "javascript" => transform_settings.javascript = true, "css" => transform_settings.css = true, "html" => transform_settings.html = true, "svg" => transform_settings.svg = true, "json" => transform_settings.json = true, _ => unreachable!(), } let transformed = crate::transform::apply(response, path, &transform_settings, false) .await .unwrap(); let transformed_plan = plan( StatusCode::OK, transformed.headers(), &cache_settings, Duration::ZERO, ) .unwrap(); let expected_etag = transformed.headers()["etag"].clone(); let expected_length = transformed.headers()["content-length"].clone(); let wrapped = cache.wrap( transformed, ticket.clone(), origin_plan.restrict(transformed_plan), &cache_settings, ); let first = wrapped.into_body().collect().await.unwrap().to_bytes(); assert!(!first.is_empty(), "{kind}"); assert_ne!( first.as_ref(), origin_body, "{kind} should change the chosen fixture" ); let hit = cache .get(&ticket) .expect("transformed representation should be cached"); assert_eq!(hit.response.headers()["etag"], expected_etag, "{kind}"); assert_eq!( hit.response.headers()["content-length"], expected_length, "{kind}" ); let second = hit.response.into_body().collect().await.unwrap().to_bytes(); assert_eq!(second, first, "{kind}"); } } #[test] fn conditional_hit_uses_weak_etag_and_last_modified() { let mut response = HeaderMap::new(); response.insert("etag", HeaderValue::from_static("W/\"abc\"")); response.insert( "last-modified", HeaderValue::from_static("Mon, 26 Jan 2026 23:11:40 GMT"), ); let mut request = HeaderMap::new(); request.insert("if-none-match", HeaderValue::from_static("\"abc\"")); assert!(conditional_not_modified(&request, &response)); request.insert("if-none-match", HeaderValue::from_static("\"other\"")); assert!(!conditional_not_modified(&request, &response)); request.remove("if-none-match"); request.insert( "if-modified-since", HeaderValue::from_static("Tue, 27 Jan 2026 00:00:00 GMT"), ); assert!(conditional_not_modified(&request, &response)); } #[test] fn query_strings_remain_distinct_and_bypass_query_is_only_a_request_policy() { let cache = Cache::new(1); let headers = HeaderMap::new(); let v1 = cache .ticket(1, "http", "example.test", "app", "/file.css?v=1", &headers) .unwrap(); let v2 = cache .ticket(1, "http", "example.test", "app", "/file.css?v=2", &headers) .unwrap(); let flag = cache .ticket(1, "http", "example.test", "app", "/file.css?F", &headers) .unwrap(); assert_ne!(v1.key, v2.key); assert_ne!(v1.key, flag.key); assert_ne!(v2.key, flag.key); let mut cache_settings = settings(); assert_eq!( request_policy( &Method::GET, &headers, true, "/file.css", true, &cache_settings, ), RequestPolicy::Use ); cache_settings.bypass_query = true; assert_eq!( request_policy( &Method::GET, &headers, true, "/file.css", true, &cache_settings, ), RequestPolicy::Bypass("query_excluded") ); } #[tokio::test] async fn transformed_client_conditionals_use_the_representation_etag_not_the_origin_etag() { let transform_settings = crate::transform::Settings { enabled: true, json: true, ..Default::default() }; let body = br#"{ "a": 1 }"#; let mut response = Response::new(Body::from(Bytes::from_static(body))); response .headers_mut() .insert("content-type", HeaderValue::from_static("application/json")); response.headers_mut().insert( "content-length", HeaderValue::from_str(&body.len().to_string()).unwrap(), ); response .headers_mut() .insert("etag", HeaderValue::from_static("\"origin\"")); let response = crate::transform::apply(response, "/data.json", &transform_settings, false) .await .unwrap(); let representation_etag = response.headers()["etag"].clone(); assert_ne!(representation_etag, HeaderValue::from_static("\"origin\"")); let mut request = HeaderMap::new(); request.insert("if-none-match", representation_etag.clone()); assert!(conditional_not_modified(&request, response.headers())); request.insert("if-none-match", HeaderValue::from_static("\"origin\"")); assert!(!conditional_not_modified(&request, response.headers())); let validators = response .extensions() .get::() .unwrap(); assert_eq!(validators.etag.as_ref().unwrap(), "\"origin\""); } #[test] fn cache_key_separates_frontend_scheme() { let cache = Cache::new(1); let headers = HeaderMap::new(); let http = cache .ticket(1, "http", "example.test", "app", "/asset", &headers) .unwrap(); let https = cache .ticket(1, "https", "example.test", "app", "/asset", &headers) .unwrap(); assert_ne!(http.key, https.key); } #[test] fn cache_key_ignores_revalidation_headers() { let cache = Cache::new(1); let base = ticket(&cache, &HeaderMap::new()); for (name, value) in [ ("cache-control", "no-cache"), ("pragma", "no-cache"), ("if-none-match", "\"origin\""), ("if-modified-since", "Mon, 26 Jan 2026 23:11:40 GMT"), ] { let mut headers = HeaderMap::new(); headers.insert( http::HeaderName::from_bytes(name.as_bytes()).unwrap(), HeaderValue::from_str(value).unwrap(), ); assert_eq!(base.key, ticket(&cache, &headers).key, "{name}"); } } #[test] fn cache_memory_defaults_are_bounded_and_sane() { let s = Settings::default(); assert_eq!(s.memory_preset, None); assert_eq!(s.cold_preset, None); assert_eq!(s.max_bytes, 256 * 1024 * 1024); assert_eq!(s.max_object_bytes, 16 * 1024 * 1024); assert_eq!(s.transient_max_bytes, 64 * 1024 * 1024); assert_eq!(s.cold_queue_max_bytes, 32 * 1024 * 1024); assert_eq!(s.hot_eviction_max_objects, 64); assert!(s.validate().is_ok()); assert!(Cache::fill_headroom_bytes(8 * 1024 * 1024) <= 128 * 1024); assert!(Cache::fill_headroom_bytes(s.max_bytes) <= 1024 * 1024); } #[test] fn cache_preset_intent_round_trips_with_configuration() { let settings = Settings { memory_preset: Some(MemoryPreset::Recommended), cold_preset: Some(ColdPreset::Recommended), ..Default::default() }; let encoded = serde_json::to_value(&settings).unwrap(); assert_eq!(encoded["memory_preset"], "recommended"); assert_eq!(encoded["cold_preset"], "recommended"); let decoded: Settings = serde_json::from_value(encoded).unwrap(); assert_eq!(decoded.memory_preset, Some(MemoryPreset::Recommended)); assert_eq!(decoded.cold_preset, Some(ColdPreset::Recommended)); } #[test] fn transient_budget_is_hard_and_released_with_lease() { let cache = Cache::new(1); let s = Settings { transient_max_bytes: 128 * 1024, cold_queue_max_bytes: 64 * 1024, ..Default::default() }; let first = cache .try_reserve_transient(96 * 1024, &s) .expect("first reservation"); assert!(cache.try_reserve_transient(40 * 1024, &s).is_none()); assert_eq!(cache.view().transient_bytes, 96 * 1024); drop(first); assert_eq!(cache.view().transient_bytes, 0); assert!(cache.try_reserve_transient(128 * 1024, &s).is_some()); } #[test] fn cold_queue_budget_cannot_exceed_transient_budget() { let s = Settings { transient_max_bytes: 8 * 1024 * 1024, cold_queue_max_bytes: 16 * 1024 * 1024, ..Default::default() }; assert!(s.validate().is_err()); } #[cfg(target_pointer_width = "64")] #[test] fn cache_limits_allow_explicit_multi_gibibyte_budgets() { let mut s = Settings { max_bytes: 4usize * 1024 * 1024 * 1024, max_object_bytes: 2usize * 1024 * 1024 * 1024, transient_max_bytes: 512usize * 1024 * 1024, cold_queue_max_bytes: 256usize * 1024 * 1024, ..Default::default() }; assert!(s.validate().is_ok()); s.max_object_bytes = s.max_bytes.saturating_add(1); assert!(s.validate().is_err()); } #[test] fn revalidation_only_reuses_matching_client_validator() { let cached = Revalidation { headers: { let mut h = HeaderMap::new(); h.insert("etag", HeaderValue::from_static("W/\"origin\"")); h.insert( "last-modified", HeaderValue::from_static("Mon, 26 Jan 2026 23:11:40 GMT"), ); h }, body: Bytes::from_static(b"body"), body_allocated: 4, _transient_leases: Vec::new(), hits: 0, transformed: false, storage_key: [0; 32], resident_age_at_lookup: Duration::ZERO, skip_compression: false, reusable_without_validation: false, age: 61, ttl: 60, stale_if_error: 30, must_revalidate: false, proxy_revalidate: false, origin_etag: Some(HeaderValue::from_static("W/\"origin\"")), origin_last_modified: Some(HeaderValue::from_static("Mon, 26 Jan 2026 23:11:40 GMT")), }; let mut matching = HeaderMap::new(); matching.insert("if-none-match", HeaderValue::from_static("\"origin\"")); assert!(cached.apply_validators(&mut matching)); let mut other = HeaderMap::new(); other.insert("if-none-match", HeaderValue::from_static("\"other\"")); assert!(!cached.apply_validators(&mut other)); assert_eq!(other["if-none-match"], "\"other\""); let mut empty = HeaderMap::new(); assert!(cached.apply_validators(&mut empty)); assert_eq!(empty["if-none-match"], "W/\"origin\""); } #[tokio::test] async fn transformed_etag_is_never_used_as_an_origin_validator() { let cache = Cache::new(1); let ticket = ticket(&cache, &HeaderMap::new()); let settings = settings(); let modified = HeaderValue::from_static("Mon, 26 Jan 2026 23:11:40 GMT"); let public_etag = HeaderValue::from_static("\"rustproxy-deadbeef\""); let mut response_headers = headers("public, max-age=60"); response_headers.insert("etag", public_etag.clone()); response_headers.insert("last-modified", modified.clone()); let plan = plan(StatusCode::OK, &response_headers, &settings, Duration::ZERO).unwrap(); let mut response = Response::new(Body::from("transformed")); *response.headers_mut() = response_headers; response .extensions_mut() .insert(crate::transform::OriginValidators { etag: None, last_modified: Some(modified.clone()), }); cache .wrap(response, ticket.clone(), plan, &settings) .into_body() .collect() .await .unwrap(); let cached = cache.revalidation(&ticket).expect("stored response"); assert!(cached.origin_etag.is_none()); assert_eq!(cached.origin_last_modified.as_ref(), Some(&modified)); let mut outbound = HeaderMap::new(); assert!(cached.apply_validators(&mut outbound)); assert!(!outbound.contains_key("if-none-match")); assert_eq!(outbound["if-modified-since"], modified); let mut client_conditional = HeaderMap::new(); client_conditional.insert("if-none-match", public_etag.clone()); assert!(cached.apply_validators(&mut client_conditional)); assert!(!client_conditional.contains_key("if-none-match")); assert_eq!(client_conditional["if-modified-since"], modified); let mut validation = HeaderMap::new(); validation.insert( "cache-control", HeaderValue::from_static("public, max-age=60"), ); validation.insert("etag", HeaderValue::from_static("\"origin-new\"")); validation.insert( "last-modified", HeaderValue::from_static("Mon, 26 Jan 2026 23:12:40 GMT"), ); let merged = cached.merge_validation_headers(&validation); assert_eq!(merged["etag"], public_etag); } #[tokio::test] async fn vary_selects_variants_without_fragmenting_the_primary_hash() { let cache = Cache::new(1); let s = settings(); let mut en = HeaderMap::new(); en.insert("accept-language", "en".parse().unwrap()); let mut pl = en.clone(); pl.insert("accept-language", "pl".parse().unwrap()); let en_ticket = ticket(&cache, &en); let pl_ticket = ticket(&cache, &pl); assert_eq!(en_ticket.key, pl_ticket.key); let mut h = headers("max-age=60"); h.insert("vary", HeaderValue::from_static("Accept-Language")); let p = plan(StatusCode::OK, &h, &s, Duration::ZERO).unwrap(); let mut response = Response::new(Body::from("hello")); *response.headers_mut() = h.clone(); cache .wrap(response, en_ticket.clone(), p, &s) .into_body() .collect() .await .unwrap(); assert!(cache.get(&en_ticket).is_some()); assert!(cache.get(&pl_ticket).is_none()); let p = plan(StatusCode::OK, &h, &s, Duration::ZERO).unwrap(); let mut response = Response::new(Body::from("czesc")); *response.headers_mut() = h; cache .wrap(response, pl_ticket.clone(), p, &s) .into_body() .collect() .await .unwrap(); let en_hit = cache.get(&en_ticket).expect("English Vary variant"); let pl_hit = cache.get(&pl_ticket).expect("Polish Vary variant"); assert_eq!( en_hit .response .into_body() .collect() .await .unwrap() .to_bytes(), Bytes::from_static(b"hello") ); assert_eq!( pl_hit .response .into_body() .collect() .await .unwrap() .to_bytes(), Bytes::from_static(b"czesc") ); let mut star = headers("max-age=60"); star.insert("vary", HeaderValue::from_static("*")); assert!(plan(StatusCode::OK, &star, &s, Duration::ZERO).is_none()); for vary in [ "x-request-id", "cache-control", "pragma", "if-none-match", "if-modified-since", ] { let mut h = headers("max-age=60"); h.insert("vary", vary.parse().unwrap()); assert!( plan(StatusCode::OK, &h, &s, Duration::ZERO).is_some(), "valid Vary field must remain cacheable: {vary}" ); } } #[tokio::test] async fn canonical_identity_drops_accept_encoding_as_a_cache_variant() { let cache = Cache::new(1); let s = settings(); let mut gzip = HeaderMap::new(); gzip.insert("accept-encoding", HeaderValue::from_static("gzip")); let mut brotli = HeaderMap::new(); brotli.insert("accept-encoding", HeaderValue::from_static("br")); let gzip_ticket = ticket(&cache, &gzip); let brotli_ticket = ticket(&cache, &brotli); let mut origin_headers = headers("public, max-age=60"); origin_headers.insert("vary", HeaderValue::from_static("Accept-Encoding")); origin_headers.insert("content-length", HeaderValue::from_static("7")); let origin_plan = plan(StatusCode::OK, &origin_headers, &s, Duration::ZERO).expect("origin plan"); let mut transformed_headers = headers("public, max-age=60"); transformed_headers.insert("content-length", HeaderValue::from_static("7")); let transformed_plan = plan(StatusCode::OK, &transformed_headers, &s, Duration::ZERO) .expect("transformed plan"); let cache_plan = origin_plan .without_vary("accept-encoding") .restrict(transformed_plan); assert!(cache_plan.vary.is_empty()); let mut response = Response::new(Body::from("payload")); *response.headers_mut() = transformed_headers; cache .wrap(response, gzip_ticket.clone(), cache_plan, &s) .into_body() .collect() .await .unwrap(); let hit = cache .get(&brotli_ticket) .expect("canonical identity must be shared across client encodings"); assert_eq!( hit.response.into_body().collect().await.unwrap().to_bytes(), Bytes::from_static(b"payload") ); } #[tokio::test] async fn canonical_identity_preserves_non_encoding_vary_dimensions() { let cache = Cache::new(1); let s = settings(); let mut light = HeaderMap::new(); light.insert("accept-encoding", HeaderValue::from_static("gzip")); light.insert("x-theme", HeaderValue::from_static("light")); let mut dark = HeaderMap::new(); dark.insert("accept-encoding", HeaderValue::from_static("br")); dark.insert("x-theme", HeaderValue::from_static("dark")); let mut origin_headers = headers("public, max-age=60"); origin_headers.insert("vary", HeaderValue::from_static("Accept-Encoding, X-Theme")); let origin_plan = plan(StatusCode::OK, &origin_headers, &s, Duration::ZERO).expect("origin plan"); let mut transformed_headers = headers("public, max-age=60"); transformed_headers.insert("vary", HeaderValue::from_static("X-Theme")); let transformed_plan = plan(StatusCode::OK, &transformed_headers, &s, Duration::ZERO) .expect("transformed plan"); let cache_plan = origin_plan .without_vary("accept-encoding") .restrict(transformed_plan); assert_eq!(cache_plan.vary, vec!["x-theme".to_owned()]); let mut response = Response::new(Body::from("light")); *response.headers_mut() = transformed_headers; cache .wrap(response, ticket(&cache, &light), cache_plan, &s) .into_body() .collect() .await .unwrap(); assert!(cache.get(&ticket(&cache, &light)).is_some()); assert!(cache.get(&ticket(&cache, &dark)).is_none()); } #[tokio::test] async fn vary_schema_change_removes_incompatible_fallbacks() { let cache = Cache::new(1); let s = settings(); let mut en = HeaderMap::new(); en.insert("accept-language", HeaderValue::from_static("en")); let mut pl = HeaderMap::new(); pl.insert("accept-language", HeaderValue::from_static("pl")); let en_ticket = ticket(&cache, &en); let pl_ticket = ticket(&cache, &pl); fill(&cache, en_ticket.clone(), "shared-old", &s).await; assert!(cache.get(&pl_ticket).is_some()); let mut varied_headers = headers("max-age=60"); varied_headers.insert("vary", HeaderValue::from_static("Accept-Language")); let varied_plan = plan(StatusCode::OK, &varied_headers, &s, Duration::ZERO).unwrap(); let mut varied = Response::new(Body::from("english-new")); *varied.headers_mut() = varied_headers; cache .wrap(varied, en_ticket.clone(), varied_plan, &s) .into_body() .collect() .await .unwrap(); assert!(cache.get(&en_ticket).is_some()); assert!( cache.get(&pl_ticket).is_none(), "an older no-Vary object must not shadow a newer Vary schema" ); fill(&cache, pl_ticket.clone(), "shared-new", &s).await; let hit = cache .get(&en_ticket) .expect("new no-Vary schema should replace old variants"); assert_eq!( hit.response.into_body().collect().await.unwrap().to_bytes(), Bytes::from_static(b"shared-new") ); } async fn fill(cache: &Arc, ticket: Ticket, data: &'static str, settings: &Settings) { let h = headers("max-age=60"); let p = plan(StatusCode::OK, &h, settings, Duration::ZERO).unwrap(); let mut r = Response::new(Body::from(data)); *r.headers_mut() = h; cache .wrap(r, ticket, p, settings) .into_body() .collect() .await .unwrap(); } #[tokio::test] async fn max_stale_can_reuse_an_object_after_normal_ttl_expiry() { let cache = Cache::new(1); let s = settings(); let initial = ticket(&cache, &HeaderMap::new()); let response_headers = headers("public, max-age=1"); let p = plan(StatusCode::OK, &response_headers, &s, Duration::ZERO).unwrap(); let mut response = Response::new(Body::from("stale-payload")); *response.headers_mut() = response_headers; cache .wrap(response, initial.clone(), p, &s) .into_body() .collect() .await .unwrap(); { let mut store = cache.store.lock().unwrap(); let entry = store.entries.values_mut().next().expect("stored object"); entry.received = Instant::now() - Duration::from_secs(2); } assert!( cache.get(&initial).is_none(), "normal request must not receive stale data" ); assert_eq!( cache.view().entries, 1, "freshness expiry must not physically evict the object" ); let stale = ticket(&cache, &headers("max-stale")); let hit = cache .get(&stale) .expect("explicit max-stale may reuse retained stale data"); assert!(hit.stale); assert_eq!( hit.response.into_body().collect().await.unwrap().to_bytes(), Bytes::from_static(b"stale-payload") ); } #[tokio::test] async fn response_no_cache_is_stored_only_for_validation() { let cache = Cache::new(1); let t = ticket(&cache, &HeaderMap::new()); let s = settings(); let mut h = headers("no-cache"); h.insert("etag", HeaderValue::from_static("\"origin\"")); let p = plan(StatusCode::OK, &h, &s, Duration::ZERO).unwrap(); assert!(p.requires_revalidation); let mut response = Response::new(Body::from("payload")); *response.headers_mut() = h; cache .wrap(response, t.clone(), p, &s) .into_body() .collect() .await .unwrap(); assert!( cache.get(&t).is_none(), "no-cache must not be reused without validation" ); let candidate = cache.revalidation(&t).expect("stored validator/body"); assert!(!candidate.reusable_without_validation()); let mut validation = HeaderMap::new(); validation.insert("cache-control", HeaderValue::from_static("no-cache")); validation.insert("etag", HeaderValue::from_static("\"origin\"")); let merged = candidate.merge_validation_headers(&validation); let hit = cache .publish_revalidated( t.clone(), &candidate, merged, &validation, &s, Duration::ZERO, ) .expect("validated body can be delivered"); assert_eq!( hit.response.into_body().collect().await.unwrap().to_bytes(), Bytes::from_static(b"payload") ); assert!( cache.get(&t).is_none(), "stored no-cache response still requires validation next time" ); } #[tokio::test] async fn resident_age_survives_successful_revalidation() { let cache = Cache::new(1); let t = ticket(&cache, &HeaderMap::new()); let s = settings(); let mut h = headers("no-cache"); h.insert("etag", HeaderValue::from_static("\"origin\"")); let p = plan(StatusCode::OK, &h, &s, Duration::ZERO).unwrap(); let mut response = Response::new(Body::from("payload")); *response.headers_mut() = h; cache .wrap(response, t.clone(), p, &s) .into_body() .collect() .await .unwrap(); { let mut store = cache.store.lock().unwrap(); let entry = store.entries.values_mut().next().expect("stored object"); entry.resident_age_base = Duration::from_secs(30); entry.resident_since = Instant::now(); } let candidate = cache.revalidation(&t).expect("revalidation candidate"); assert!(candidate.resident_age_at_lookup >= Duration::from_secs(30)); let mut validation = HeaderMap::new(); validation.insert( "cache-control", HeaderValue::from_static("public, max-age=60"), ); validation.insert("etag", HeaderValue::from_static("\"origin\"")); let merged = candidate.merge_validation_headers(&validation); let hit = cache .publish_revalidated( t, &candidate, merged, &validation, &s, Duration::from_secs(2), ) .expect("validated object"); assert!(hit.resident_age_seconds >= 32); } #[tokio::test] async fn complete_body_hit_age_and_purge() { let cache = Cache::new(1); let t = ticket(&cache, &HeaderMap::new()); assert!(cache.get(&t).is_none()); fill(&cache, t.clone(), "payload", &settings()).await; let view = cache.view(); assert_eq!(view.hot_logical_bytes, b"payload".len()); assert_eq!(view.hot_allocated_bytes, view.resident_bytes); assert!(view.hot_allocated_bytes > view.hot_logical_bytes); assert!(view.hot_allocated_bytes <= view.hot_limit_bytes); let hit = cache.get(&t).unwrap(); assert_eq!(hit.hits, 1); assert!(hit.response.headers().contains_key("age")); assert_eq!( hit.response.into_body().collect().await.unwrap().to_bytes(), Bytes::from_static(b"payload") ); assert_eq!(cache.purge(None), 1); assert!(cache.get(&t).is_none()); } #[tokio::test] async fn cache_hit_and_dashboard_age_advance_from_storage_clock() { let cache = Cache::new(1); let t = ticket(&cache, &HeaderMap::new()); fill(&cache, t.clone(), "payload", &settings()).await; { let mut store = cache.store.lock().unwrap_or_else(|e| e.into_inner()); let entry = store.entries.values_mut().next().expect("stored entry"); entry.received = Instant::now() - Duration::from_secs(5); } let hit = cache.get(&t).expect("fresh cached response"); assert!( hit.response.headers()["age"] .to_str() .unwrap() .parse::() .unwrap() >= 5 ); assert!(cache.dashboard(None, 0, 50).objects[0].age_seconds >= 5); } #[test] fn defaults_enable_static_cookie_boost_for_common_assets() { let settings = Settings::default(); assert!(settings.enabled); assert!(settings.static_ignore_cookies); for path in [ "/asset.jpg", "/asset.jpeg", "/asset.png", "/app.css", "/app.js", "/font.woff2", "/audio.mp3", "/note.txt", ] { assert!(settings.ignore_cookie_for_path(path), "{path}"); } assert!(!settings.ignore_cookie_for_path("/account")); assert!(!settings.ignore_cookie_for_path("/api/profile.json")); } #[test] fn static_cookie_bypass_is_limited_to_configured_extensions() { let mut settings = settings(); settings.static_ignore_cookies = true; let mut h = HeaderMap::new(); h.insert("cookie", HeaderValue::from_static("session=secret")); assert_eq!( request_reason(&Method::GET, &h, true, "/asset.jpg", false, &settings), None ); assert_eq!( request_reason(&Method::GET, &h, true, "/account", false, &settings), Some("request_cookie") ); } #[test] fn age_uses_corrected_received_age_plus_response_delay_without_rounding_up() { let settings = settings(); let mut h = headers("max-age=120"); h.insert( "date", httpdate::fmt_http_date(SystemTime::now() - Duration::from_secs(10)) .parse() .unwrap(), ); h.insert("age", HeaderValue::from_static("3")); let age_plan = plan(StatusCode::OK, &h, &settings, Duration::from_millis(250)).unwrap(); assert!(age_plan.initial_age >= Duration::from_secs(10)); assert!(age_plan.initial_age < Duration::from_secs(12)); let fresh = plan( StatusCode::OK, &headers("max-age=60"), &settings, Duration::from_millis(250), ) .unwrap(); assert_eq!(fresh.initial_age.as_secs(), 0); } #[test] fn aged_upstream_freshness_caps_remaining_not_total_age() { let settings = settings(); let mut headers = headers("public, max-age=2592000"); headers.insert( "date", httpdate::fmt_http_date(SystemTime::now()).parse().unwrap(), ); headers.insert("age", HeaderValue::from_static("1553483")); headers.insert("etag", HeaderValue::from_static("\"origin-etag\"")); let original = plan(StatusCode::OK, &headers, &settings, Duration::ZERO).unwrap(); assert_eq!(original.initial_age.as_secs(), 1553483); assert_eq!(original.remaining_ttl(), settings.max_ttl_seconds); assert_eq!(original.ttl, 1553483 + settings.max_ttl_seconds); headers.insert("age", HeaderValue::from_static("2591990")); let almost_expired = plan(StatusCode::OK, &headers, &settings, Duration::ZERO).unwrap(); assert_eq!(almost_expired.remaining_ttl(), 10); headers.insert("age", HeaderValue::from_static("2592010")); assert_eq!( plan(StatusCode::OK, &headers, &settings, Duration::ZERO) .unwrap() .remaining_ttl(), 0 ); headers.insert( "cache-control", HeaderValue::from_static("public, no-cache, max-age=2592000"), ); assert!( plan(StatusCode::OK, &headers, &settings, Duration::ZERO) .unwrap() .requires_revalidation ); headers.insert( "cache-control", HeaderValue::from_static("private, max-age=2592000"), ); assert!(plan(StatusCode::OK, &headers, &settings, Duration::ZERO).is_none()); } #[test] fn age_uses_rfc9111_max_of_apparent_and_corrected_age() { let s = settings(); let mut h = headers("public, max-age=3600"); h.insert( "date", httpdate::fmt_http_date(SystemTime::now() - Duration::from_secs(10)) .parse() .unwrap(), ); h.insert("age", HeaderValue::from_static("3")); let p = plan(StatusCode::OK, &h, &s, Duration::from_secs(2)).unwrap(); assert!(p.initial_age < Duration::from_secs(12)); assert!(p.initial_age >= Duration::from_secs(10)); h.insert("age", HeaderValue::from_static("40")); let p = plan(StatusCode::OK, &h, &s, Duration::from_secs(2)).unwrap(); assert_eq!(p.initial_age.as_secs(), 42); } #[tokio::test] async fn aged_transformed_css_remains_hot_and_visible_in_dashboard() { let cache = Cache::new(1); let settings = settings(); let ticket = ticket(&cache, &HeaderMap::new()); let original_body = b"body { color: red; margin: 0px; }"; let mut upstream = Response::new(Body::from(Bytes::from_static(original_body))); *upstream.headers_mut() = headers("public, max-age=2592000"); upstream.headers_mut().insert( "date", httpdate::fmt_http_date(SystemTime::now()).parse().unwrap(), ); upstream .headers_mut() .insert("age", HeaderValue::from_static("1553483")); upstream .headers_mut() .insert("content-type", HeaderValue::from_static("text/css")); upstream.headers_mut().insert( "content-length", HeaderValue::from_str(&original_body.len().to_string()).unwrap(), ); upstream .headers_mut() .insert("etag", HeaderValue::from_static("\"upstream\"")); let origin_plan = plan( StatusCode::OK, upstream.headers(), &settings, Duration::ZERO, ) .unwrap(); let transformed = crate::transform::apply( upstream, "/theme.css", &crate::transform::Settings { enabled: true, css: true, ..Default::default() }, false, ) .await .unwrap(); assert!(transformed .extensions() .get::() .is_some()); assert_eq!(transformed.headers()["age"], "1553483"); let transformed_plan = plan( StatusCode::OK, transformed.headers(), &settings, Duration::ZERO, ) .unwrap(); let combined = origin_plan.restrict(transformed_plan); assert!(combined.remaining_ttl() > 86_000); let first = cache .wrap(transformed, ticket.clone(), combined, &settings) .into_body() .collect() .await .unwrap() .to_bytes(); let hit = cache .get(&ticket) .expect("aged, fresh CSS should be cached after transform"); assert!(hit.transformed); assert!(hit.remaining_ttl >= 86_000); assert!( hit.response.headers()["age"] .to_str() .unwrap() .parse::() .unwrap() >= 1553483 ); let second = hit.response.into_body().collect().await.unwrap().to_bytes(); assert_eq!(first, second); let dash = cache.dashboard(None, 0, 10); assert_eq!(dash.matching_objects, 1); assert!(dash.objects[0].transformed); assert!(dash.objects[0].age_seconds >= 1_553_483); assert!(dash.objects[0].resident_age_seconds < 10); assert!(dash.objects[0].remaining_ttl_seconds >= 86_000); assert_eq!(dash.objects[0].hits, 1); } #[tokio::test] async fn purged_inflight_fill_cannot_repopulate() { let cache = Cache::new(1); let t = ticket(&cache, &HeaderMap::new()); cache.purge(None); fill(&cache, t, "old", &settings()).await; assert_eq!(cache.view().entries, 0); } #[tokio::test] async fn revision_isolation_and_capacity() { let cache = Cache::new(1); let s = Settings { max_entries: 1, ..settings() }; let a = ticket(&cache, &HeaderMap::new()); fill(&cache, a.clone(), "a", &s).await; let h = HeaderMap::new(); let b = cache .ticket(1, "http", "example.test", "app", "/other", &h) .unwrap(); fill(&cache, b.clone(), "b", &s).await; assert!(cache.get(&a).is_none()); assert!(cache.get(&b).is_some()); assert_eq!(cache.view().evictions, 1); cache.purge(Some(2)); assert!(cache .ticket(1, "http", "example.test", "app", "/", &h) .is_none()); } #[tokio::test] async fn hot_hit_updates_lru_without_replacing_the_entry_or_losing_hit_counter() { let cache = Cache::new(1); let settings = Settings { max_entries: 2, ..settings() }; let h = HeaderMap::new(); let a = cache .ticket(1, "http", "example.test", "app", "/a", &h) .unwrap(); let b = cache .ticket(1, "http", "example.test", "app", "/b", &h) .unwrap(); let c = cache .ticket(1, "http", "example.test", "app", "/c", &h) .unwrap(); fill(&cache, a.clone(), "payload-a", &settings).await; fill(&cache, b.clone(), "payload-b", &settings).await; assert_eq!(cache.get(&a).unwrap().hits, 1); assert_eq!(cache.get(&a).unwrap().hits, 2); fill(&cache, c.clone(), "payload-c", &settings).await; assert!( cache.get(&b).is_none(), "the least recently used object is evicted" ); assert_eq!( cache.get(&a).unwrap().hits, 3, "HOT hits do not reset on LRU refresh" ); assert_eq!(cache.get(&c).unwrap().hits, 1); assert_eq!(cache.view().evictions, 1); assert_eq!(cache.view().entries, 2); } #[tokio::test] async fn hot_byte_budget_evicts_before_another_fill_grows() { let cache = Cache::new(1); let s = Settings { max_bytes: 96 * 1024, max_object_bytes: 64 * 1024, max_entries: 16, transient_max_bytes: 128 * 1024, cold_queue_max_bytes: 64 * 1024, ..settings() }; let headers = HeaderMap::new(); let first = cache .ticket(1, "http", "example.test", "app", "/first", &headers) .unwrap(); let second = cache .ticket(1, "http", "example.test", "app", "/second", &headers) .unwrap(); fill(&cache, first.clone(), "first", &s).await; assert!(cache.get(&first).is_some()); let before = cache.view(); assert!(before.hot_allocated_bytes <= s.max_bytes); fill(&cache, second.clone(), "second", &s).await; let after = cache.view(); assert!(after.hot_allocated_bytes <= s.max_bytes); assert_eq!(after.fill_bytes_active, 0); assert_eq!(after.transient_bytes, 0); assert!( cache.get(&first).is_none(), "LRU entry should make room before the next fill allocation" ); assert!(cache.get(&second).is_some()); assert!(after.evictions >= 1); } #[tokio::test] async fn dropped_partial_bodies_and_oversize_are_never_cached() { let cache = Cache::new(1); let t = ticket(&cache, &HeaderMap::new()); let s = settings(); let h = headers("max-age=60"); let p = plan(StatusCode::OK, &h, &s, Duration::ZERO).unwrap(); let r = Response::new(Body::from("not-consumed")); drop(cache.wrap(r, t.clone(), p, &s)); assert!(cache.get(&t).is_none()); assert_eq!(cache.view().fills_active, 0); let small = Settings { max_object_bytes: 2, ..s }; let h = headers("max-age=60"); let p = plan(StatusCode::OK, &h, &small, Duration::ZERO).unwrap(); let mut response = Response::new(Body::from("oversize")); *response.headers_mut() = h; let streamed = cache .wrap(response, t.clone(), p, &small) .into_body() .collect() .await .unwrap() .to_bytes(); assert_eq!(streamed, Bytes::from_static(b"oversize")); assert!(cache.get(&t).is_none()); assert_eq!(cache.view().fill_bytes_active, 0); assert_eq!(cache.view().transient_bytes, 0); } #[test] fn varnish_primary_hash_uses_host_and_url_not_unrelated_request_headers() { let cache = Cache::new(1); let mut a = HeaderMap::new(); a.append("user-agent", "browser-a".parse().unwrap()); a.append("sec-ch-ua", "brand-a".parse().unwrap()); let mut b = HeaderMap::new(); b.append("user-agent", "browser-b".parse().unwrap()); b.append("x-forwarded-for", "192.0.2.8".parse().unwrap()); let base = cache .ticket( 1, "http", "Example.Test", "pool/member-a", "/asset.js?x=1", &a, ) .unwrap(); let other_member = cache .ticket( 1, "http", "example.test", "pool/member-b", "/asset.js?x=1", &b, ) .unwrap(); assert_eq!(base.key, other_member.key); let other_url = cache .ticket( 1, "http", "example.test", "pool/member-b", "/asset.js?x=2", &b, ) .unwrap(); assert_ne!(base.key, other_url.key); let explicit_port = cache .ticket( 1, "http", "example.test:8080", "pool/member-a", "/asset.js?x=1", &a, ) .unwrap(); assert_ne!(base.key, explicit_port.key); } #[tokio::test] async fn only_if_cached_can_reuse_a_normal_get() { let cache = Cache::new(1); let plain = HeaderMap::new(); fill(&cache, ticket(&cache, &plain), "payload", &settings()).await; let only = headers("only-if-cached"); assert!(request_cacheable( &Method::GET, &only, true, "/", false, &settings() )); assert!(only_if_cached(&only)); assert!(cache.get(&ticket(&cache, &only)).is_some()); } #[tokio::test] async fn dropped_partial_fill_is_not_published_and_a_later_get_can_fill() { use futures_util::stream; use http_body_util::StreamBody; let cache = Cache::new(1); let s = settings(); let t = ticket(&cache, &HeaderMap::new()); let frames = vec![ Ok::<_, std::io::Error>(Frame::data(Bytes::from_static(b"first-"))), Ok(Frame::data(Bytes::from_static(b"second"))), ]; let mut response = Response::new(Body::new(StreamBody::new(stream::iter(frames)))); *response.headers_mut() = headers("max-age=60"); response .headers_mut() .insert("content-length", HeaderValue::from_static("12")); let p = plan(StatusCode::OK, response.headers(), &s, Duration::ZERO).unwrap(); let mut body = cache.wrap(response, t.clone(), p, &s).into_body(); let first = body .frame() .await .expect("first frame") .expect("successful first frame") .into_data() .expect("data frame"); assert_eq!(first, Bytes::from_static(b"first-")); drop(body); assert!( cache.get(&t).is_none(), "partial body must never become a HIT" ); assert_eq!(cache.view().fills_active, 0); assert_eq!(cache.view().rejected_fills, 1); fill(&cache, t.clone(), "complete", &s).await; let hit = cache .get(&t) .expect("a later complete GET can fill the cache"); assert_eq!( hit.response.into_body().collect().await.unwrap().to_bytes(), Bytes::from_static(b"complete") ); } #[tokio::test] async fn failed_streams_and_trailers_cannot_publish_entries() { use futures_util::stream; use http_body_util::StreamBody; for fail in [true, false] { let cache = Cache::new(1); let s = settings(); let t = ticket(&cache, &HeaderMap::new()); let frames = vec![ Ok(Frame::data(Bytes::from_static(b"partial"))), if fail { Err(std::io::Error::other("broken origin")) } else { Ok(Frame::trailers(HeaderMap::new())) }, ]; let mut r = Response::new(Body::new(StreamBody::new(stream::iter(frames)))); *r.headers_mut() = headers("max-age=60"); let p = plan(StatusCode::OK, r.headers(), &s, Duration::ZERO).unwrap(); let result = cache.wrap(r, t.clone(), p, &s).into_body().collect().await; assert_eq!(result.is_err(), fail); assert!(cache.get(&t).is_none()); assert_eq!(cache.view().fills_active, 0); } } #[test] fn empty_complete_body_is_cached_without_requiring_a_consumer_poll() { let cache = Cache::new(1); let settings = settings(); let ticket = ticket(&cache, &HeaderMap::new()); let mut response = Response::new(Body::empty()); *response.headers_mut() = headers("public, max-age=600"); response .headers_mut() .insert("content-length", HeaderValue::from_static("0")); let plan = plan( StatusCode::OK, response.headers(), &settings, Duration::ZERO, ) .unwrap(); let response = cache.wrap(response, ticket.clone(), plan, &settings); assert!(response.body().is_end_stream()); drop(response); assert!(cache.get(&ticket).is_some()); assert_eq!(cache.view().entries, 1); assert_eq!(cache.view().fills_active, 0); assert_eq!(cache.view().rejected_fills, 0); } #[test] fn empty_body_with_nonzero_content_length_cannot_publish_a_fill() { let cache = Cache::new(1); let settings = settings(); let ticket = ticket(&cache, &HeaderMap::new()); let mut response = Response::new(Body::empty()); *response.headers_mut() = headers("public, max-age=600"); response .headers_mut() .insert("content-length", HeaderValue::from_static("6")); let plan = plan( StatusCode::OK, response.headers(), &settings, Duration::ZERO, ) .unwrap(); drop(cache.wrap(response, ticket.clone(), plan, &settings)); assert!(cache.get(&ticket).is_none()); assert_eq!(cache.view().fills_active, 0); assert_eq!(cache.view().rejected_fills, 1); } #[test] fn empty_body_completion_still_obeys_purge_barrier() { let cache = Cache::new(1); let settings = settings(); let ticket = ticket(&cache, &HeaderMap::new()); let mut response = Response::new(Body::empty()); *response.headers_mut() = headers("public, max-age=600"); response .headers_mut() .insert("content-length", HeaderValue::from_static("0")); let plan = plan( StatusCode::OK, response.headers(), &settings, Duration::ZERO, ) .unwrap(); cache.purge(None); drop(cache.wrap(response, ticket.clone(), plan, &settings)); assert!(cache.get(&ticket).is_none()); assert_eq!(cache.view().entries, 0); assert_eq!(cache.view().fills_active, 0); } }