@@ -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.
213213static 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