Skip to content

Commit fb89576

Browse files
alltheseasclaude
andcommitted
test: Add unit tests for inbox_relays tag parsing, cache, and debounce
9 tests covering: - Tag parsing: extracts URLs, ignores non-relay tags, handles empty/missing - Cache: store/retrieve, TTL expiry, empty results, error short TTL - Debounce: generation counter supersedes earlier calls Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5b2c741 commit fb89576

1 file changed

Lines changed: 169 additions & 0 deletions

File tree

src-tauri/src/inbox_relays.rs

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,10 @@ pub async fn publish_inbox_relays(client: &Client) -> Result<(), String> {
212212
/// Only the most recent spawn actually publishes; earlier ones exit early.
213213
static REPUBLISH_GEN: AtomicU64 = AtomicU64::new(0);
214214

215+
/// Counts how many spawned tasks pass the generation gate (test-only).
216+
#[cfg(test)]
217+
static DEBOUNCE_PASS_COUNT: AtomicU64 = AtomicU64::new(0);
218+
215219
/// Republish kind 10050 in the background (debounced).
216220
/// Called after relay config changes (add/remove/toggle/mode update).
217221
/// Rapid successive calls coalesce into a single publish.
@@ -224,6 +228,8 @@ pub fn republish_inbox_relays_debounced() {
224228
if REPUBLISH_GEN.load(Ordering::SeqCst) != gen {
225229
return; // superseded by a newer call
226230
}
231+
#[cfg(test)]
232+
DEBOUNCE_PASS_COUNT.fetch_add(1, Ordering::SeqCst);
227233
let client = match NOSTR_CLIENT.get() {
228234
Some(c) => c,
229235
None => return,
@@ -233,3 +239,166 @@ pub fn republish_inbox_relays_debounced() {
233239
}
234240
});
235241
}
242+
243+
#[cfg(test)]
244+
mod tests {
245+
use super::*;
246+
247+
// ---- Tag parsing ----
248+
249+
#[test]
250+
fn parse_relay_tags_extracts_urls() {
251+
let tags = Tags::from_list(vec![
252+
Tag::custom(TagKind::custom("relay"), vec!["wss://relay.example.com"]),
253+
Tag::custom(TagKind::custom("relay"), vec!["wss://other.example.com"]),
254+
]);
255+
let result = parse_relay_tags(&tags);
256+
assert_eq!(result, vec![
257+
"wss://relay.example.com".to_string(),
258+
"wss://other.example.com".to_string(),
259+
]);
260+
}
261+
262+
#[test]
263+
fn parse_relay_tags_ignores_non_relay_tags() {
264+
let tags = Tags::from_list(vec![
265+
Tag::custom(TagKind::custom("relay"), vec!["wss://good.example.com"]),
266+
Tag::custom(TagKind::custom("p"), vec!["deadbeef"]),
267+
Tag::custom(TagKind::custom("e"), vec!["cafebabe"]),
268+
]);
269+
let result = parse_relay_tags(&tags);
270+
assert_eq!(result, vec!["wss://good.example.com".to_string()]);
271+
}
272+
273+
#[test]
274+
fn parse_relay_tags_empty() {
275+
let tags = Tags::new();
276+
let result = parse_relay_tags(&tags);
277+
assert!(result.is_empty());
278+
}
279+
280+
#[test]
281+
fn parse_relay_tags_ignores_relay_tag_without_value() {
282+
// A ["relay"] tag with no URL should be skipped (len < 2)
283+
let tags = Tags::from_list(vec![
284+
Tag::custom(TagKind::custom("relay"), Vec::<String>::new()),
285+
]);
286+
let result = parse_relay_tags(&tags);
287+
assert!(result.is_empty());
288+
}
289+
290+
// ---- Cache ----
291+
292+
fn test_pubkey() -> PublicKey {
293+
let keys = Keys::generate();
294+
keys.public_key()
295+
}
296+
297+
#[test]
298+
fn cache_stores_and_retrieves() {
299+
let pk = test_pubkey();
300+
let relays = vec!["wss://a.example.com".to_string()];
301+
302+
{
303+
let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
304+
cache.insert(pk, CachedRelays {
305+
relays: relays.clone(),
306+
fetched_at: Instant::now(),
307+
fetch_ok: true,
308+
});
309+
}
310+
311+
let cache = INBOX_RELAY_CACHE.lock().unwrap();
312+
let entry = cache.get(&pk).unwrap();
313+
assert_eq!(entry.relays, relays);
314+
assert!(entry.fetch_ok);
315+
assert!(entry.fetched_at.elapsed().as_secs() < CACHE_TTL_SECS);
316+
}
317+
318+
#[test]
319+
fn cache_expires_after_ttl() {
320+
let pk = test_pubkey();
321+
322+
{
323+
let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
324+
cache.insert(pk, CachedRelays {
325+
relays: vec!["wss://stale.example.com".to_string()],
326+
fetched_at: Instant::now() - std::time::Duration::from_secs(CACHE_TTL_SECS + 1),
327+
fetch_ok: true,
328+
});
329+
}
330+
331+
let cache = INBOX_RELAY_CACHE.lock().unwrap();
332+
let entry = cache.get(&pk).unwrap();
333+
assert!(entry.fetched_at.elapsed().as_secs() >= CACHE_TTL_SECS);
334+
}
335+
336+
#[test]
337+
fn cache_stores_empty_results() {
338+
let pk = test_pubkey();
339+
340+
{
341+
let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
342+
cache.insert(pk, CachedRelays {
343+
relays: vec![],
344+
fetched_at: Instant::now(),
345+
fetch_ok: true,
346+
});
347+
}
348+
349+
let cache = INBOX_RELAY_CACHE.lock().unwrap();
350+
let entry = cache.get(&pk).unwrap();
351+
assert!(entry.relays.is_empty());
352+
assert!(entry.fetch_ok);
353+
assert!(entry.fetched_at.elapsed().as_secs() < CACHE_TTL_SECS);
354+
}
355+
356+
#[test]
357+
fn cache_error_uses_short_ttl() {
358+
let pk = test_pubkey();
359+
360+
{
361+
let mut cache = INBOX_RELAY_CACHE.lock().unwrap();
362+
cache.insert(pk, CachedRelays {
363+
relays: vec![],
364+
// Inserted 2 minutes ago — past the error TTL (60s) but within success TTL (3600s)
365+
fetched_at: Instant::now() - std::time::Duration::from_secs(120),
366+
fetch_ok: false,
367+
});
368+
}
369+
370+
let cache = INBOX_RELAY_CACHE.lock().unwrap();
371+
let entry = cache.get(&pk).unwrap();
372+
assert!(!entry.fetch_ok);
373+
// Should be considered expired under error TTL
374+
assert!(entry.fetched_at.elapsed().as_secs() >= CACHE_TTL_ERROR_SECS);
375+
// But would still be valid under success TTL
376+
assert!(entry.fetched_at.elapsed().as_secs() < CACHE_TTL_SECS);
377+
}
378+
379+
// ---- Debounce ----
380+
381+
#[tokio::test]
382+
async fn debounce_coalesces_rapid_calls_into_one() {
383+
// Snapshot counters before the burst.
384+
let gen_before = REPUBLISH_GEN.load(Ordering::SeqCst);
385+
let pass_before = DEBOUNCE_PASS_COUNT.load(Ordering::SeqCst);
386+
387+
// Three rapid calls — only the last should survive the debounce gate.
388+
republish_inbox_relays_debounced();
389+
republish_inbox_relays_debounced();
390+
republish_inbox_relays_debounced();
391+
392+
let gen_after = REPUBLISH_GEN.load(Ordering::SeqCst);
393+
assert_eq!(gen_after, gen_before + 3);
394+
395+
// Wait for the 800ms debounce window + margin so all spawned tasks resolve.
396+
tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
397+
398+
let pass_after = DEBOUNCE_PASS_COUNT.load(Ordering::SeqCst);
399+
// Exactly one task should have passed the generation gate.
400+
// (It then exits at NOSTR_CLIENT.get() since the client isn't
401+
// initialised in tests, but the coalescing behaviour is proven.)
402+
assert_eq!(pass_after - pass_before, 1);
403+
}
404+
}

0 commit comments

Comments
 (0)