notecrumbs

a nostr opengraph server build on nostrdb and egui
git clone git://jb55.com/notecrumbs
Log | Files | Refs | README | LICENSE

commit 3c41c014b971c8857dcf9ab708adf0a9c904c50c
parent 1f54dc46b2d22252e171db48ea89524a7806e29d
Author: William Casarin <jb55@jb55.com>
Date:   Wed, 18 Feb 2026 12:29:00 -0800

test: add tests for debounce and inflight deduplication logic

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Diffstat:
Msrc/main.rs | 316+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 316 insertions(+), 0 deletions(-)

diff --git a/src/main.rs b/src/main.rs @@ -690,3 +690,319 @@ fn spawn_relay_pool_metrics_logger(pool: Arc<RelayPool>) { } }); } + +#[cfg(test)] +mod tests { + use super::*; + use nostr::nips::nip19::{Nip19Coordinate, Nip19Profile}; + use std::sync::atomic::{AtomicUsize, Ordering}; + + /// Helper: create a fresh DashMap wrapped in Arc for testing + fn new_state_map() -> Arc<DashMap<[u8; 32], RefreshState>> { + Arc::new(DashMap::new()) + } + + /// Helper: a test key (arbitrary 32 bytes) + fn test_key(byte: u8) -> [u8; 32] { + [byte; 32] + } + + /// Helper: spawn a no-op task that completes immediately, tracking call count + fn counting_task( + counter: Arc<AtomicUsize>, + ) -> impl FnOnce(Arc<DashMap<[u8; 32], RefreshState>>, [u8; 32]) -> tokio::task::JoinHandle<()> + { + move |state_map, key| { + counter.fetch_add(1, Ordering::SeqCst); + tokio::spawn(async move { + state_map.insert(key, RefreshState::Completed(Instant::now())); + }) + } + } + + // --------------------------------------------------------------- + // nip19_debounce_key tests + // --------------------------------------------------------------- + + #[test] + fn debounce_key_event_uses_event_id() { + let event_id = EventId::all_zeros(); + let nip19 = Nip19::EventId(event_id); + assert_eq!(nip19_debounce_key(&nip19), *event_id.as_bytes()); + } + + #[test] + fn debounce_key_pubkey_uses_pubkey_bytes() { + let keys = Keys::generate(); + let pk = keys.public_key(); + let nip19 = Nip19::Pubkey(pk); + assert_eq!(nip19_debounce_key(&nip19), pk.to_bytes()); + } + + #[test] + fn debounce_key_profile_uses_pubkey_bytes() { + let keys = Keys::generate(); + let pk = keys.public_key(); + let nip19 = Nip19::Profile(Nip19Profile::new(pk, [])); + assert_eq!(nip19_debounce_key(&nip19), pk.to_bytes()); + } + + #[test] + fn debounce_key_coordinate_is_deterministic() { + use nostr::nips::nip01::Coordinate; + let keys = Keys::generate(); + let coord = Coordinate::new(Kind::LongFormTextNote, keys.public_key()) + .identifier("test-article"); + let nip19 = Nip19::Coordinate(Nip19Coordinate::new(coord, [])); + let key1 = nip19_debounce_key(&nip19); + let key2 = nip19_debounce_key(&nip19); + assert_eq!(key1, key2); + } + + #[test] + fn debounce_key_different_coordinates_differ() { + use nostr::nips::nip01::Coordinate; + let keys = Keys::generate(); + let coord_a = Coordinate::new(Kind::LongFormTextNote, keys.public_key()) + .identifier("article-a"); + let coord_b = Coordinate::new(Kind::LongFormTextNote, keys.public_key()) + .identifier("article-b"); + let nip19_a = Nip19::Coordinate(Nip19Coordinate::new(coord_a, [])); + let nip19_b = Nip19::Coordinate(Nip19Coordinate::new(coord_b, [])); + assert_ne!(nip19_debounce_key(&nip19_a), nip19_debounce_key(&nip19_b)); + } + + // --------------------------------------------------------------- + // try_spawn_debounced tests + // --------------------------------------------------------------- + + #[tokio::test] + async fn debounce_spawns_on_first_call() { + let state = new_state_map(); + let counter = Arc::new(AtomicUsize::new(0)); + let key = test_key(1); + + let spawned = try_spawn_debounced(&state, key, Duration::from_secs(300), counting_task(counter.clone())); + + assert!(spawned); + assert_eq!(counter.load(Ordering::SeqCst), 1); + // State should show InProgress (task may have completed already, but the + // entry was set before the task ran) + assert!(state.contains_key(&key)); + } + + #[tokio::test] + async fn debounce_skips_while_in_progress() { + let state = new_state_map(); + let key = test_key(2); + + // Insert a fake InProgress entry + state.insert( + key, + RefreshState::InProgress { + started: Instant::now(), + handle: tokio::spawn(async {}).abort_handle(), + }, + ); + + let counter = Arc::new(AtomicUsize::new(0)); + let spawned = try_spawn_debounced(&state, key, Duration::from_secs(300), counting_task(counter.clone())); + + assert!(!spawned); + assert_eq!(counter.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn debounce_skips_recently_completed() { + let state = new_state_map(); + let key = test_key(3); + + // Insert a Completed entry from just now + state.insert(key, RefreshState::Completed(Instant::now())); + + let counter = Arc::new(AtomicUsize::new(0)); + let spawned = try_spawn_debounced(&state, key, Duration::from_secs(300), counting_task(counter.clone())); + + assert!(!spawned); + assert_eq!(counter.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn debounce_refreshes_after_interval_expires() { + let state = new_state_map(); + let key = test_key(4); + + // Insert a Completed entry from "long ago" (past the interval) + let old_time = Instant::now() - Duration::from_secs(600); + state.insert(key, RefreshState::Completed(old_time)); + + let counter = Arc::new(AtomicUsize::new(0)); + let spawned = try_spawn_debounced(&state, key, Duration::from_secs(300), counting_task(counter.clone())); + + assert!(spawned); + assert_eq!(counter.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn debounce_aborts_stuck_task_and_retries() { + let state = new_state_map(); + let key = test_key(5); + + // Insert InProgress from >10 minutes ago (stuck) + let stuck_time = Instant::now() - Duration::from_secs(11 * 60); + let stuck_handle = tokio::spawn(async { std::future::pending::<()>().await }); + let abort_handle = stuck_handle.abort_handle(); + state.insert( + key, + RefreshState::InProgress { + started: stuck_time, + handle: abort_handle.clone(), + }, + ); + + let counter = Arc::new(AtomicUsize::new(0)); + let spawned = try_spawn_debounced(&state, key, Duration::from_secs(300), counting_task(counter.clone())); + + assert!(spawned, "should retry after stuck task"); + assert_eq!(counter.load(Ordering::SeqCst), 1); + // The old task should have been aborted — yield to let the runtime process it + tokio::task::yield_now().await; + assert!(stuck_handle.is_finished()); + } + + #[tokio::test] + async fn debounce_does_not_abort_recent_in_progress() { + let state = new_state_map(); + let key = test_key(6); + + // Insert InProgress from just now (not stuck) + let handle = tokio::spawn(async { std::future::pending::<()>().await }); + state.insert( + key, + RefreshState::InProgress { + started: Instant::now(), + handle: handle.abort_handle(), + }, + ); + + let counter = Arc::new(AtomicUsize::new(0)); + let spawned = try_spawn_debounced(&state, key, Duration::from_secs(300), counting_task(counter.clone())); + + assert!(!spawned); + assert_eq!(counter.load(Ordering::SeqCst), 0); + // The original task should NOT have been aborted + assert!(!handle.is_finished()); + handle.abort(); // cleanup + } + + #[tokio::test] + async fn debounce_prunes_stale_entries_over_threshold() { + let state = new_state_map(); + let old_time = Instant::now() - Duration::from_secs(600); + let interval = Duration::from_secs(300); + + // Fill the map past the threshold with stale Completed entries + for i in 0..(REFRESH_MAP_PRUNE_THRESHOLD + 50) { + let mut key = [0u8; 32]; + key[0] = (i & 0xFF) as u8; + key[1] = ((i >> 8) & 0xFF) as u8; + state.insert(key, RefreshState::Completed(old_time)); + } + + assert!(state.len() > REFRESH_MAP_PRUNE_THRESHOLD); + + // The next call should trigger pruning + let key = test_key(0xFF); + let counter = Arc::new(AtomicUsize::new(0)); + try_spawn_debounced(&state, key, interval, counting_task(counter.clone())); + + // Stale entries should have been pruned (only the new one + any InProgress remain) + assert!( + state.len() < REFRESH_MAP_PRUNE_THRESHOLD, + "state map should have been pruned, but has {} entries", + state.len() + ); + } + + #[tokio::test] + async fn debounce_independent_keys_both_spawn() { + let state = new_state_map(); + let key_a = test_key(0xAA); + let key_b = test_key(0xBB); + + let counter = Arc::new(AtomicUsize::new(0)); + let spawned_a = try_spawn_debounced(&state, key_a, Duration::from_secs(300), counting_task(counter.clone())); + let spawned_b = try_spawn_debounced(&state, key_b, Duration::from_secs(300), counting_task(counter.clone())); + + assert!(spawned_a); + assert!(spawned_b); + assert_eq!(counter.load(Ordering::SeqCst), 2); + } + + // --------------------------------------------------------------- + // Inflight deduplication tests (Notify-based) + // --------------------------------------------------------------- + + #[tokio::test] + async fn inflight_dedup_concurrent_waiters_share_one_fetch() { + let inflight: Arc<DashMap<[u8; 32], Arc<tokio::sync::Notify>>> = Arc::new(DashMap::new()); + let key = test_key(0xCC); + let fetch_count = Arc::new(AtomicUsize::new(0)); + + // Simulate the "first request" pattern: insert Notify, do work, signal + let n = Arc::new(tokio::sync::Notify::new()); + inflight.insert(key, n.clone()); + + // Spawn 10 "waiter" tasks that find the existing Notify and wait + let mut waiters = Vec::new(); + for _ in 0..10 { + let inflight = inflight.clone(); + let fetch_count = fetch_count.clone(); + let key = key; + waiters.push(tokio::spawn(async move { + if let Some(notify) = inflight.get(&key).map(|r| r.value().clone()) { + // This is the "waiter" path — no fetch + notify.notified().await; + } else { + // This would be the "fetcher" path — shouldn't happen + fetch_count.fetch_add(1, Ordering::SeqCst); + } + })); + } + + // Give waiters a moment to subscribe + tokio::task::yield_now().await; + + // Simulate the fetch completing + fetch_count.fetch_add(1, Ordering::SeqCst); + inflight.remove(&key); + n.notify_waiters(); + + // All waiters should complete + for w in waiters { + w.await.unwrap(); + } + + // Only 1 fetch should have happened (the original, not any waiters) + assert_eq!(fetch_count.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn inflight_second_request_after_completion_can_fetch() { + let inflight: Arc<DashMap<[u8; 32], Arc<tokio::sync::Notify>>> = Arc::new(DashMap::new()); + let key = test_key(0xDD); + + // First "request" — insert, do work, remove, notify + { + let n = Arc::new(tokio::sync::Notify::new()); + inflight.insert(key, n.clone()); + // ... fetch happens ... + inflight.remove(&key); + n.notify_waiters(); + } + + // Second "request" — should NOT find an inflight entry + let found = inflight.get(&key).is_some(); + assert!(!found, "inflight entry should have been cleaned up"); + } +}