use super::*;
use core::time::Duration;
use std::time::Instant;
fn make_entry(
name: &str,
rtype: ResourceType,
rdata: u8,
ttl_secs: u64,
) -> (Name, ResourceType, std::vec::Vec<u8>, Duration) {
(
Name::try_from_str(name).unwrap(),
rtype,
std::vec![rdata],
Duration::from_secs(ttl_secs),
)
}
#[test]
fn cache_evicts_on_max_entries() {
let mut cache: Cache<Instant, slab::Slab<CacheEntry<Instant>>> = Cache::with_max_entries(4);
let now = Instant::now();
for i in 0u8..5 {
let name = std::format!("entry{}.local.", i);
let (n, rt, rd, ttl) = make_entry(&name, ResourceType::A, i, 30);
cache
.try_insert(n, rt, ResourceClass::In, rd, ttl, now, false)
.unwrap();
}
assert!(
cache.entries.len() <= 4,
"expected at most 4 entries after 5 inserts with max_entries=4, got {}",
cache.entries.len()
);
}
#[test]
fn len_and_is_empty_track_entry_count() {
let mut cache: Cache<Instant, slab::Slab<CacheEntry<Instant>>> = Cache::new();
assert!(cache.is_empty());
assert_eq!(cache.len(), 0);
let now = Instant::now();
let (n, rt, rd, ttl) = make_entry("len.local.", ResourceType::A, 1, 30);
cache
.try_insert(n, rt, ResourceClass::In, rd, ttl, now, false)
.unwrap();
assert!(!cache.is_empty());
assert_eq!(cache.len(), 1);
}
#[cfg(feature = "stats")]
#[test]
fn cache_insert_and_eviction_bump_stats() {
use std::sync::Arc;
let mut cache: Cache<Instant, slab::Slab<CacheEntry<Instant>>> = Cache::with_max_entries(2);
cache.set_stats(Arc::new(hick_trace::stats::Stats::default()));
let now = Instant::now();
for i in 0u8..4 {
let (n, rt, rd, ttl) = make_entry(&std::format!("e{}.local.", i), ResourceType::A, i, 30);
cache
.try_insert(n, rt, ResourceClass::In, rd, ttl, now, false)
.unwrap();
}
assert!(cache.len() <= 2);
}
#[test]
fn max_entries_accessor_and_default_cap() {
let custom: Cache<Instant, slab::Slab<CacheEntry<Instant>>> = Cache::with_max_entries(9);
assert_eq!(custom.max_entries(), 9);
let default: Cache<Instant, slab::Slab<CacheEntry<Instant>>> = Cache::default();
assert_eq!(default.max_entries(), DEFAULT_MAX_ENTRIES);
}
#[test]
fn cache_flush_insert_walks_siblings_and_inserts_fresh() {
let mut c: Cache<Instant, slab::Slab<CacheEntry<Instant>>> = Cache::new();
let now = Instant::now();
let name = "host.local.";
let (n, rt, rd, ttl) = make_entry(name, ResourceType::A, 1, 120);
c.try_insert(n, rt, ResourceClass::In, rd, ttl, now, false)
.unwrap();
let (n2, rt2, rd2, ttl2) = make_entry(name, ResourceType::A, 2, 120);
c.try_insert(n2, rt2, ResourceClass::In, rd2, ttl2, now, true)
.unwrap();
assert_eq!(
c.count_matching(
&Name::try_from_str(name).unwrap(),
ResourceType::A,
ResourceClass::In
),
2
);
}
#[test]
fn ttl_zero_goodbye_clamps_to_one_second_not_immediate_delete() {
let mut c: Cache<Instant, slab::Slab<CacheEntry<Instant>>> = Cache::new();
let (n, rt, rd, ttl) = make_entry("host.local.", ResourceType::A, 7, 120);
let now = Instant::now();
c.try_insert(
n.clone(),
rt,
ResourceClass::In,
rd.clone(),
ttl,
now,
false,
)
.unwrap();
assert!(c.contains(&n, rt, ResourceClass::In));
c.try_insert(
n.clone(),
rt,
ResourceClass::In,
rd.clone(),
Duration::ZERO,
now,
false,
)
.unwrap();
c.sweep_expired(now);
assert!(
c.contains(&n, rt, ResourceClass::In),
"TTL=0 must NOT delete immediately (§10.1 rescue window)"
);
c.sweep_expired(now + Duration::from_secs(2));
assert!(
!c.contains(&n, rt, ResourceClass::In),
"the clamped entry must expire ~1s after the goodbye"
);
}
#[test]
fn ttl_zero_goodbye_can_be_rescued_by_reannounce() {
let mut c: Cache<Instant, slab::Slab<CacheEntry<Instant>>> = Cache::new();
let (n, rt, rd, ttl) = make_entry("host.local.", ResourceType::A, 7, 120);
let now = Instant::now();
c.try_insert(
n.clone(),
rt,
ResourceClass::In,
rd.clone(),
ttl,
now,
false,
)
.unwrap();
c.try_insert(
n.clone(),
rt,
ResourceClass::In,
rd.clone(),
Duration::ZERO,
now,
false,
)
.unwrap();
c.try_insert(
n.clone(),
rt,
ResourceClass::In,
rd.clone(),
ttl,
now,
false,
)
.unwrap();
c.sweep_expired(now + Duration::from_secs(5));
assert!(
c.contains(&n, rt, ResourceClass::In),
"a re-announce within the rescue window must restore the record"
);
}
#[test]
fn cache_flush_evicts_existing_entries_for_same_name_rtype() {
let mut c: Cache<Instant, slab::Slab<CacheEntry<Instant>>> = Cache::new();
let name = Name::try_from_str("host.local.").unwrap();
let now = Instant::now();
let ttl = Duration::from_secs(120);
c.try_insert(
name.clone(),
ResourceType::A,
ResourceClass::In,
std::vec![10, 0, 0, 1],
ttl,
now,
false,
)
.unwrap();
c.try_insert(
name.clone(),
ResourceType::A,
ResourceClass::In,
std::vec![10, 0, 0, 2],
ttl,
now,
false,
)
.unwrap();
assert_eq!(c.entries.len(), 2, "expected 2 entries before flush");
let after_grace = now.checked_add(Duration::from_secs(2)).unwrap();
c.try_insert(
name.clone(),
ResourceType::A,
ResourceClass::In,
std::vec![10, 0, 0, 99],
ttl,
after_grace,
true,
)
.unwrap();
let after_clamp = after_grace.checked_add(Duration::from_secs(2)).unwrap();
c.sweep_expired(after_clamp);
assert_eq!(
c.entries.len(),
1,
"cache_flush=true after §10.2 grace + sweep must leave exactly 1 entry"
);
let surviving = c
.entries
.iter()
.next()
.map(|(_, e)| e.rdata_slice().to_vec());
assert_eq!(
surviving,
Some(std::vec![10, 0, 0, 99]),
"surviving rdata must be the cache-flush record"
);
}
#[test]
fn cache_flush_does_not_evict_different_rtype() {
let mut c: Cache<Instant, slab::Slab<CacheEntry<Instant>>> = Cache::new();
let name = Name::try_from_str("host.local.").unwrap();
let now = Instant::now();
let ttl = Duration::from_secs(120);
c.try_insert(
name.clone(),
ResourceType::AAAA,
ResourceClass::In,
std::vec![0xfe, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
ttl,
now,
false,
)
.unwrap();
c.try_insert(
name.clone(),
ResourceType::A,
ResourceClass::In,
std::vec![10, 0, 0, 1],
ttl,
now,
true,
)
.unwrap();
assert_eq!(
c.entries.len(),
2,
"cache_flush for A must not evict the AAAA entry"
);
assert!(
c.contains(&name, ResourceType::AAAA, ResourceClass::In),
"AAAA entry must survive a cache_flush targeting A"
);
assert!(
c.contains(&name, ResourceType::A, ResourceClass::In),
"A entry (flush record) must be present"
);
}
#[test]
fn cache_flush_respects_max_entries() {
let mut c: Cache<Instant, slab::Slab<CacheEntry<Instant>>> = Cache::with_max_entries(3);
let now = Instant::now();
let ttl = Duration::from_secs(120);
c.try_insert(
Name::try_from_str("a.local.").unwrap(),
ResourceType::A,
ResourceClass::In,
std::vec![1],
ttl,
now,
false,
)
.unwrap();
c.try_insert(
Name::try_from_str("b.local.").unwrap(),
ResourceType::A,
ResourceClass::In,
std::vec![2],
ttl,
now,
false,
)
.unwrap();
c.try_insert(
Name::try_from_str("c.local.").unwrap(),
ResourceType::A,
ResourceClass::In,
std::vec![3],
ttl,
now,
false,
)
.unwrap();
assert_eq!(c.entries.len(), 3, "cache must be full before flush test");
c.try_insert(
Name::try_from_str("d.local.").unwrap(),
ResourceType::A,
ResourceClass::In,
std::vec![4],
ttl,
now,
true, )
.unwrap();
let count = c.entries.len();
assert!(
count <= 3,
"cache must not grow past max_entries=3 after cache_flush insert; got {count}"
);
}
#[test]
fn zero_cap_cache_rejects_inserts() {
let mut c: Cache<Instant, slab::Slab<CacheEntry<Instant>>> = Cache::with_max_entries(0);
let now = Instant::now();
let ttl = Duration::from_secs(120);
let key = c
.try_insert(
Name::try_from_str("a.local.").unwrap(),
ResourceType::A,
ResourceClass::In,
std::vec![1],
ttl,
now,
false,
)
.unwrap();
assert!(
key.is_none(),
"zero-cap cache must return Ok(None) on regular insert"
);
assert_eq!(c.entries.len(), 0);
let key = c
.try_insert(
Name::try_from_str("b.local.").unwrap(),
ResourceType::A,
ResourceClass::In,
std::vec![2],
ttl,
now,
true,
)
.unwrap();
assert!(
key.is_none(),
"zero-cap cache must return Ok(None) on cache_flush insert"
);
assert_eq!(
c.entries.len(),
0,
"zero-cap cache must remain empty after cache_flush insert"
);
}
#[test]
fn ttl_zero_goodbye_for_absent_record_is_noop() {
let mut c: Cache<Instant, slab::Slab<CacheEntry<Instant>>> = Cache::new();
let now = Instant::now();
let (n, rt, rd, ttl) = make_entry("present.local.", ResourceType::A, 1, 120);
c.try_insert(n, rt, ResourceClass::In, rd, ttl, now, false)
.unwrap();
assert_eq!(c.entries.len(), 1);
let absent = Name::try_from_str("absent.local.").unwrap();
let key = c
.try_insert(
absent.clone(),
ResourceType::A,
ResourceClass::In,
std::vec![9],
Duration::ZERO,
now,
false,
)
.unwrap();
assert!(key.is_none(), "TTL=0 goodbye must return Ok(None)");
assert_eq!(
c.entries.len(),
1,
"a goodbye for an absent record must not insert or remove anything"
);
assert!(
!c.contains(&absent, ResourceType::A, ResourceClass::In),
"the absent record must remain absent after its goodbye"
);
}
#[test]
fn ttl_zero_goodbye_does_not_extend_a_sooner_expiry() {
let mut c: Cache<Instant, slab::Slab<CacheEntry<Instant>>> = Cache::new();
let now = Instant::now();
let (n, rt, rd, ttl) = make_entry("brief.local.", ResourceType::A, 1, 1);
c.try_insert(
n.clone(),
rt,
ResourceClass::In,
rd.clone(),
ttl,
now,
false,
)
.unwrap();
let before = c
.entries
.iter()
.next()
.map(|(_, e)| e.expires_at())
.unwrap();
c.try_insert(n, rt, ResourceClass::In, rd, Duration::ZERO, now, false)
.unwrap();
let after = c
.entries
.iter()
.next()
.map(|(_, e)| e.expires_at())
.unwrap();
assert_eq!(
before, after,
"a goodbye must never push a sooner-or-equal expiry further out"
);
}
#[test]
fn cache_flush_grace_treats_future_received_at_as_recent() {
let mut c: Cache<Instant, slab::Slab<CacheEntry<Instant>>> = Cache::new();
let base = Instant::now();
let ttl = Duration::from_secs(120);
let name = Name::try_from_str("host.local.").unwrap();
let future = base.checked_add(Duration::from_secs(10)).unwrap();
c.try_insert(
name.clone(),
ResourceType::A,
ResourceClass::In,
std::vec![10, 0, 0, 1],
ttl,
future,
false,
)
.unwrap();
c.try_insert(
name.clone(),
ResourceType::A,
ResourceClass::In,
std::vec![10, 0, 0, 2],
ttl,
base,
true,
)
.unwrap();
c.sweep_expired(base.checked_add(Duration::from_secs(5)).unwrap());
assert_eq!(
c.count_matching(&name, ResourceType::A, ResourceClass::In),
2,
"a future-received sibling must be treated as recent (no §10.2 clamp)"
);
}
#[cfg(feature = "heapless")]
#[test]
fn fallible_pool_capacity_error_triggers_reactive_eviction() {
let mut c: Cache<Instant, heapless::Vec<Option<CacheEntry<Instant>>, 2>> =
Cache::with_max_entries(100);
#[cfg(feature = "stats")]
c.set_stats(std::sync::Arc::new(hick_trace::stats::Stats::default()));
let now = Instant::now();
let name = Name::try_from_str("host.local.").unwrap();
c.try_insert(
name.clone(),
ResourceType::A,
ResourceClass::In,
std::vec![10, 0, 0, 1],
Duration::from_secs(10),
now,
false,
)
.unwrap();
c.try_insert(
name.clone(),
ResourceType::A,
ResourceClass::In,
std::vec![10, 0, 0, 2],
Duration::from_secs(300),
now,
false,
)
.unwrap();
assert_eq!(c.len(), 2, "pool is now at its fixed capacity of 2");
let key = c
.try_insert(
name.clone(),
ResourceType::A,
ResourceClass::In,
std::vec![10, 0, 0, 3],
Duration::from_secs(300),
now,
false,
)
.unwrap();
assert!(
key.is_some(),
"reactive eviction + retry must insert the new entry successfully"
);
assert_eq!(c.len(), 2, "cache must stay at the pool's fixed capacity");
let surviving: std::vec::Vec<std::vec::Vec<u8>> = c
.entries
.iter()
.map(|(_, e)| e.rdata_slice().to_vec())
.collect();
assert!(
!surviving.contains(&std::vec![10, 0, 0, 1]),
"the soonest-expiring entry must be the reactive-eviction victim"
);
assert!(
surviving.contains(&std::vec![10, 0, 0, 2]),
"the later-expiring sibling must survive reactive eviction"
);
assert!(
surviving.contains(&std::vec![10, 0, 0, 3]),
"the newly inserted entry must be present after the retry"
);
}