From b101afa00bc28e1b55286145cb81e32a5b3decc9 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Wed, 14 Jan 2026 10:19:18 +0000 Subject: feat(sync): add rejected events cache persistence and integrate with shutdown/startup Implement save/restore functionality for rejected events cache and integrate persistence with relay shutdown/startup lifecycle. Both purgatory and rejected cache now survive relay restarts. Key features: - Serialize rejected events cache to JSON (rejected-events-cache.json) - Save both hot cache (2min, full events) and cold index (7day, metadata) - Restore with downtime adjustment (preserves remaining TTL) - Graceful degradation (missing/corrupted files don't crash) - File cleanup after successful restore - Automatic restoration in SyncManager::new() Integration: - Shutdown hook saves both purgatory and rejected cache - Startup hook restores both and re-queues repositories - Non-fatal errors (logs warnings, continues on failure) Files: - src/sync/rejected_index.rs: save_to_disk/restore_from_disk methods - src/sync/mod.rs: SyncManager integration and auto-restore - src/main.rs: Shutdown/startup hooks for both caches - tests/purgatory_persistence.rs: 17 integration tests Tests: 13 unit tests + 17 integration tests covering full lifecycle --- tests/purgatory_persistence.rs | 755 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 755 insertions(+) create mode 100644 tests/purgatory_persistence.rs (limited to 'tests') diff --git a/tests/purgatory_persistence.rs b/tests/purgatory_persistence.rs new file mode 100644 index 0000000..acefb41 --- /dev/null +++ b/tests/purgatory_persistence.rs @@ -0,0 +1,755 @@ +//! Purgatory Persistence Integration Tests +//! +//! Tests that verify the full purgatory persistence save/restore cycle: +//! - Purgatory save/restore with state events, PR events, and expired events +//! - Rejected cache save/restore with hot cache and cold index entries +//! - Integration with shutdown/startup hooks +//! - Graceful degradation with missing or corrupted files +//! - Time adjustment for downtime +//! +//! # Test Strategy +//! +//! These tests verify end-to-end persistence functionality: +//! 1. Create purgatory/rejected cache instances with various entries +//! 2. Save state to disk +//! 3. Create new instances and restore from disk +//! 4. Verify all data is restored correctly +//! 5. Verify system continues to work after restore +//! +//! # Running Tests +//! +//! ```bash +//! # Run all purgatory persistence tests +//! cargo test --test purgatory_persistence +//! +//! # Run specific test +//! cargo test --test purgatory_persistence test_full_purgatory_save_restore_cycle +//! +//! # With output for debugging +//! cargo test --test purgatory_persistence -- --nocapture +//! ``` + +mod common; + +use ngit_grasp::purgatory::Purgatory; +use ngit_grasp::sync::rejected_index::{EventType, RejectedEventsIndex, RejectionReason}; +use nostr_sdk::prelude::*; +use std::time::Duration; + +/// Helper to create a test event +async fn create_test_event(keys: &Keys, content: &str) -> Event { + EventBuilder::text_note(content) + .sign_with_keys(keys) + .unwrap() +} + +/// Helper to create a state event with specific refs +fn create_state_event_with_refs( + keys: &Keys, + identifier: &str, + refs: &[(&str, &str)], +) -> Result> { + let mut tags = vec![Tag::identifier(identifier)]; + + // Add ref tags + for (ref_name, commit_hash) in refs { + tags.push(Tag::custom( + TagKind::custom("ref"), + vec![ref_name.to_string(), commit_hash.to_string()], + )); + } + + let event = EventBuilder::new(Kind::from(30618), "") + .tags(tags) + .sign_with_keys(keys)?; + + Ok(event) +} + +/// Test 1: Full save/restore cycle with state events, PR events, and expired events +#[tokio::test] +async fn test_full_purgatory_save_restore_cycle() { + let temp_dir = tempfile::tempdir().unwrap(); + let git_data_path = temp_dir.path().join("git"); + let state_path = temp_dir.path().join("purgatory.json"); + + // Create purgatory instance + let purgatory = Purgatory::new(&git_data_path); + + // Create test keys and events + let keys1 = Keys::generate(); + let keys2 = Keys::generate(); + let keys3 = Keys::generate(); + + let state_event1 = + create_state_event_with_refs(&keys1, "repo1", &[("main", "abc123")]).unwrap(); + let state_event2 = + create_state_event_with_refs(&keys2, "repo2", &[("main", "def456")]).unwrap(); + + let pr_event1 = create_test_event(&keys3, "PR 1").await; + let pr_event2 = create_test_event(&keys3, "PR 2").await; + + // Add state events to purgatory + purgatory.add_state( + state_event1.clone(), + "repo1".to_string(), + keys1.public_key(), + ); + purgatory.add_state( + state_event2.clone(), + "repo2".to_string(), + keys2.public_key(), + ); + + // Add PR events to purgatory + purgatory.add_pr( + pr_event1.clone(), + pr_event1.id.to_hex(), + "commit-abc".to_string(), + ); + purgatory.add_pr( + pr_event2.clone(), + pr_event2.id.to_hex(), + "commit-def".to_string(), + ); + + // Add a PR placeholder (git-data-first scenario) + purgatory.add_pr_placeholder("placeholder-id".to_string(), "commit-xyz".to_string()); + + // Note: We can't directly test expired events without accessing private fields, + // so we'll focus on testing state and PR events persistence + + // Verify initial counts + let (state_count, pr_count) = purgatory.count(); + assert_eq!(state_count, 2, "Should have 2 state events"); + assert_eq!( + pr_count, 3, + "Should have 3 PR events (2 events + 1 placeholder)" + ); + + // Save to disk + purgatory.save_to_disk(&state_path).unwrap(); + assert!(state_path.exists(), "State file should exist after save"); + + // Create new purgatory instance and restore + let purgatory2 = Purgatory::new(&git_data_path); + purgatory2.restore_from_disk(&state_path).unwrap(); + + // Verify state file was deleted after restore + assert!( + !state_path.exists(), + "State file should be deleted after restore" + ); + + // Verify all data was restored + let (state_count2, pr_count2) = purgatory2.count(); + assert_eq!(state_count2, 2, "Should have 2 state events after restore"); + assert_eq!( + pr_count2, 3, + "Should have 3 PR events after restore (2 events + 1 placeholder)" + ); + + // Verify specific state events + let repo1_states = purgatory2.find_state("repo1"); + assert_eq!(repo1_states.len(), 1); + assert_eq!(repo1_states[0].event.id, state_event1.id); + + let repo2_states = purgatory2.find_state("repo2"); + assert_eq!(repo2_states.len(), 1); + assert_eq!(repo2_states[0].event.id, state_event2.id); + + // Verify PR events + let pr1 = purgatory2.find_pr(&pr_event1.id.to_hex()); + assert!(pr1.is_some()); + assert_eq!(pr1.unwrap().commit, "commit-abc"); + + let pr2 = purgatory2.find_pr(&pr_event2.id.to_hex()); + assert!(pr2.is_some()); + assert_eq!(pr2.unwrap().commit, "commit-def"); + + // Verify placeholder + let placeholder = purgatory2.find_pr_placeholder("placeholder-id"); + assert_eq!(placeholder, Some("commit-xyz".to_string())); + + // Verify re-queueing works - get all identifiers + let identifiers = purgatory2.get_all_identifiers(); + assert_eq!(identifiers.len(), 2); + assert!(identifiers.contains(&"repo1".to_string())); + assert!(identifiers.contains(&"repo2".to_string())); +} + +/// Test 2: Rejected cache integration - save/restore hot cache and cold index +#[tokio::test] +async fn test_rejected_cache_save_restore_cycle() { + let temp_dir = tempfile::tempdir().unwrap(); + let state_path = temp_dir.path().join("rejected_cache.json"); + + // Create rejected events index + let index = RejectedEventsIndex::new(Duration::from_secs(120), Duration::from_secs(604800)); + + // Create test events + let keys1 = Keys::generate(); + let keys2 = Keys::generate(); + + let event1 = create_test_event(&keys1, "announcement 1").await; + let event2 = create_test_event(&keys2, "announcement 2").await; + let event3 = create_test_event(&keys1, "state 1").await; + + // Add announcements to rejected cache + index.add_announcement( + event1.clone(), + event1.pubkey, + "repo1".to_string(), + RejectionReason::DoesNotListService, + ); + + index.add_announcement( + event2.clone(), + event2.pubkey, + "repo2".to_string(), + RejectionReason::MaintainerNotYetValid, + ); + + // Add state event to rejected cache + index.add_state( + event3.clone(), + event3.pubkey, + "repo1".to_string(), + RejectionReason::Other, + ); + + // Verify initial counts + assert_eq!(index.hot_cache_len(), 3); + assert_eq!(index.cold_index_len(), 3); + + // Save to disk + index.save_to_disk(&state_path).unwrap(); + assert!(state_path.exists()); + + // Create new index and restore + let index2 = RejectedEventsIndex::new(Duration::from_secs(120), Duration::from_secs(604800)); + index2.restore_from_disk(&state_path).unwrap(); + + // Verify state file was deleted + assert!(!state_path.exists()); + + // Verify all entries restored + assert_eq!(index2.hot_cache_len(), 3); + assert_eq!(index2.cold_index_len(), 3); + + // Verify specific entries + assert!(index2.contains(&event1.id)); + assert!(index2.contains(&event2.id)); + assert!(index2.contains(&event3.id)); + + // Verify we can invalidate and get events + let (removed, hot_events) = + index2.invalidate_and_get(&event1.pubkey, "repo1", Some(EventType::Announcement)); + assert_eq!(removed, 1); + assert_eq!(hot_events.len(), 1); + assert_eq!(hot_events[0].id, event1.id); +} + +/// Test 3: Simulated downtime - verify expiry times are adjusted correctly +#[tokio::test] +async fn test_purgatory_downtime_adjustment() { + let temp_dir = tempfile::tempdir().unwrap(); + let git_data_path = temp_dir.path().join("git"); + let state_path = temp_dir.path().join("purgatory.json"); + + let purgatory = Purgatory::new(&git_data_path); + let keys = Keys::generate(); + + let state_event = create_state_event_with_refs(&keys, "repo1", &[("main", "abc123")]) + .unwrap(); + + purgatory.add_state(state_event.clone(), "repo1".to_string(), keys.public_key()); + + // Save to disk + purgatory.save_to_disk(&state_path).unwrap(); + + // Simulate downtime + tokio::time::sleep(Duration::from_millis(100)).await; + + // Restore + let purgatory2 = Purgatory::new(&git_data_path); + purgatory2.restore_from_disk(&state_path).unwrap(); + + // Verify event is still there (downtime was accounted for) + let (state_count, _) = purgatory2.count(); + assert_eq!(state_count, 1); + + let repo1_states = purgatory2.find_state("repo1"); + assert_eq!(repo1_states.len(), 1); + assert_eq!(repo1_states[0].event.id, state_event.id); + + // Verify the event hasn't expired yet (expiry time was adjusted) + // The event should have ~30 minutes minus the downtime + let entry = &repo1_states[0]; + let remaining = entry + .expires_at + .saturating_duration_since(std::time::Instant::now()); + assert!( + remaining > Duration::from_secs(1700), + "Event should have most of its 30min expiry remaining" + ); +} + +/// Test 4: Rejected cache downtime adjustment +#[tokio::test] +async fn test_rejected_cache_downtime_adjustment() { + let temp_dir = tempfile::tempdir().unwrap(); + let state_path = temp_dir.path().join("rejected_cache.json"); + + let index = RejectedEventsIndex::new(Duration::from_secs(120), Duration::from_secs(604800)); + let keys = Keys::generate(); + + let event = create_test_event(&keys, "test").await; + + index.add_announcement( + event.clone(), + event.pubkey, + "repo1".to_string(), + RejectionReason::DoesNotListService, + ); + + // Save to disk + index.save_to_disk(&state_path).unwrap(); + + // Simulate downtime + tokio::time::sleep(Duration::from_millis(100)).await; + + // Restore + let index2 = RejectedEventsIndex::new(Duration::from_secs(120), Duration::from_secs(604800)); + index2.restore_from_disk(&state_path).unwrap(); + + // Verify event is still in both caches (downtime was accounted for) + assert_eq!(index2.hot_cache_len(), 1); + assert_eq!(index2.cold_index_len(), 1); + assert!(index2.contains(&event.id)); +} + +/// Test 5: File cleanup - verify state files are deleted after successful restore +#[tokio::test] +async fn test_purgatory_file_cleanup_after_restore() { + let temp_dir = tempfile::tempdir().unwrap(); + let git_data_path = temp_dir.path().join("git"); + let state_path = temp_dir.path().join("purgatory.json"); + + let purgatory = Purgatory::new(&git_data_path); + let keys = Keys::generate(); + + let state_event = create_state_event_with_refs(&keys, "repo1", &[("main", "abc123")]) + .unwrap(); + + purgatory.add_state(state_event, "repo1".to_string(), keys.public_key()); + + // Save to disk + purgatory.save_to_disk(&state_path).unwrap(); + assert!(state_path.exists(), "State file should exist after save"); + + // Restore + let purgatory2 = Purgatory::new(&git_data_path); + purgatory2.restore_from_disk(&state_path).unwrap(); + + // Verify file was deleted + assert!( + !state_path.exists(), + "State file should be deleted after successful restore" + ); +} + +/// Test 6: Rejected cache file cleanup +#[tokio::test] +async fn test_rejected_cache_file_cleanup_after_restore() { + let temp_dir = tempfile::tempdir().unwrap(); + let state_path = temp_dir.path().join("rejected_cache.json"); + + let index = RejectedEventsIndex::new(Duration::from_secs(120), Duration::from_secs(604800)); + let keys = Keys::generate(); + + let event = create_test_event(&keys, "test").await; + + index.add_announcement( + event, + keys.public_key(), + "repo1".to_string(), + RejectionReason::DoesNotListService, + ); + + // Save to disk + index.save_to_disk(&state_path).unwrap(); + assert!(state_path.exists()); + + // Restore + let index2 = RejectedEventsIndex::new(Duration::from_secs(120), Duration::from_secs(604800)); + index2.restore_from_disk(&state_path).unwrap(); + + // Verify file was deleted + assert!(!state_path.exists()); +} + +/// Test 7: Graceful degradation - missing purgatory file +#[tokio::test] +async fn test_purgatory_restore_missing_file() { + let temp_dir = tempfile::tempdir().unwrap(); + let git_data_path = temp_dir.path().join("git"); + let state_path = temp_dir.path().join("nonexistent.json"); + + let purgatory = Purgatory::new(&git_data_path); + + // Attempting to restore missing file should return error + let result = purgatory.restore_from_disk(&state_path); + assert!(result.is_err(), "Should error on missing file"); + + // Purgatory should still be usable (empty state) + let (state_count, pr_count) = purgatory.count(); + assert_eq!(state_count, 0); + assert_eq!(pr_count, 0); + + // Should be able to add events normally + let keys = Keys::generate(); + let event = create_test_event(&keys, "test").await; + purgatory.add_state(event, "repo1".to_string(), keys.public_key()); + + let (state_count, _) = purgatory.count(); + assert_eq!(state_count, 1); +} + +/// Test 8: Graceful degradation - missing rejected cache file +#[tokio::test] +async fn test_rejected_cache_restore_missing_file() { + let temp_dir = tempfile::tempdir().unwrap(); + let state_path = temp_dir.path().join("nonexistent.json"); + + let index = RejectedEventsIndex::new(Duration::from_secs(120), Duration::from_secs(604800)); + + // Attempting to restore missing file should return error + let result = index.restore_from_disk(&state_path); + assert!(result.is_err()); + + // Index should still be usable (empty state) + assert_eq!(index.hot_cache_len(), 0); + assert_eq!(index.cold_index_len(), 0); + + // Should be able to add events normally + let keys = Keys::generate(); + let event = create_test_event(&keys, "test").await; + index.add_announcement( + event, + keys.public_key(), + "repo1".to_string(), + RejectionReason::DoesNotListService, + ); + + assert_eq!(index.hot_cache_len(), 1); + assert_eq!(index.cold_index_len(), 1); +} + +/// Test 9: Graceful degradation - corrupted purgatory file +#[tokio::test] +async fn test_purgatory_restore_corrupted_file() { + let temp_dir = tempfile::tempdir().unwrap(); + let git_data_path = temp_dir.path().join("git"); + let state_path = temp_dir.path().join("corrupted.json"); + + // Write corrupted JSON + std::fs::write(&state_path, "{ invalid json !!!").unwrap(); + + let purgatory = Purgatory::new(&git_data_path); + + // Attempting to restore corrupted file should return error + let result = purgatory.restore_from_disk(&state_path); + assert!(result.is_err(), "Should error on corrupted file"); + + // Purgatory should still be usable + let (state_count, pr_count) = purgatory.count(); + assert_eq!(state_count, 0); + assert_eq!(pr_count, 0); +} + +/// Test 10: Graceful degradation - corrupted rejected cache file +#[tokio::test] +async fn test_rejected_cache_restore_corrupted_file() { + let temp_dir = tempfile::tempdir().unwrap(); + let state_path = temp_dir.path().join("corrupted.json"); + + // Write corrupted JSON + std::fs::write(&state_path, "{ invalid json !!!").unwrap(); + + let index = RejectedEventsIndex::new(Duration::from_secs(120), Duration::from_secs(604800)); + + // Attempting to restore corrupted file should return error + let result = index.restore_from_disk(&state_path); + assert!(result.is_err()); + + // Index should still be usable + assert_eq!(index.hot_cache_len(), 0); + assert_eq!(index.cold_index_len(), 0); +} + +/// Test 11: Empty purgatory save/restore +#[tokio::test] +async fn test_empty_purgatory_save_restore() { + let temp_dir = tempfile::tempdir().unwrap(); + let git_data_path = temp_dir.path().join("git"); + let state_path = temp_dir.path().join("purgatory.json"); + + let purgatory = Purgatory::new(&git_data_path); + + // Save empty purgatory + purgatory.save_to_disk(&state_path).unwrap(); + assert!(state_path.exists()); + + // Restore + let purgatory2 = Purgatory::new(&git_data_path); + purgatory2.restore_from_disk(&state_path).unwrap(); + + // Verify empty state + let (state_count, pr_count) = purgatory2.count(); + assert_eq!(state_count, 0); + assert_eq!(pr_count, 0); + assert_eq!(purgatory2.expired_count(), 0); +} + +/// Test 12: Empty rejected cache save/restore +#[tokio::test] +async fn test_empty_rejected_cache_save_restore() { + let temp_dir = tempfile::tempdir().unwrap(); + let state_path = temp_dir.path().join("rejected_cache.json"); + + let index = RejectedEventsIndex::new(Duration::from_secs(120), Duration::from_secs(604800)); + + // Save empty cache + index.save_to_disk(&state_path).unwrap(); + assert!(state_path.exists()); + + // Restore + let index2 = RejectedEventsIndex::new(Duration::from_secs(120), Duration::from_secs(604800)); + index2.restore_from_disk(&state_path).unwrap(); + + // Verify empty state + assert_eq!(index2.hot_cache_len(), 0); + assert_eq!(index2.cold_index_len(), 0); +} + +/// Test 13: Multiple state events for same identifier +#[tokio::test] +async fn test_purgatory_multiple_state_events_same_identifier() { + let temp_dir = tempfile::tempdir().unwrap(); + let git_data_path = temp_dir.path().join("git"); + let state_path = temp_dir.path().join("purgatory.json"); + + let purgatory = Purgatory::new(&git_data_path); + + // Create multiple state events for same identifier (different maintainers) + let keys1 = Keys::generate(); + let keys2 = Keys::generate(); + + let event1 = create_state_event_with_refs(&keys1, "repo1", &[("main", "abc123")]) + .unwrap(); + let event2 = create_state_event_with_refs(&keys2, "repo1", &[("main", "def456")]) + .unwrap(); + + purgatory.add_state(event1.clone(), "repo1".to_string(), keys1.public_key()); + purgatory.add_state(event2.clone(), "repo1".to_string(), keys2.public_key()); + + // Save and restore + purgatory.save_to_disk(&state_path).unwrap(); + + let purgatory2 = Purgatory::new(&git_data_path); + purgatory2.restore_from_disk(&state_path).unwrap(); + + // Verify both events restored + let repo1_states = purgatory2.find_state("repo1"); + assert_eq!(repo1_states.len(), 2); + + let event_ids: Vec<_> = repo1_states.iter().map(|e| e.event.id).collect(); + assert!(event_ids.contains(&event1.id)); + assert!(event_ids.contains(&event2.id)); +} + +/// Test 14: Verify system continues to work after restore +#[tokio::test] +async fn test_purgatory_continues_working_after_restore() { + let temp_dir = tempfile::tempdir().unwrap(); + let git_data_path = temp_dir.path().join("git"); + let state_path = temp_dir.path().join("purgatory.json"); + + let purgatory = Purgatory::new(&git_data_path); + let keys = Keys::generate(); + + let event1 = create_state_event_with_refs(&keys, "repo1", &[("main", "abc123")]) + .unwrap(); + + purgatory.add_state(event1.clone(), "repo1".to_string(), keys.public_key()); + + // Save and restore + purgatory.save_to_disk(&state_path).unwrap(); + + let purgatory2 = Purgatory::new(&git_data_path); + purgatory2.restore_from_disk(&state_path).unwrap(); + + // Add new events after restore + let event2 = create_state_event_with_refs(&keys, "repo2", &[("main", "xyz789")]) + .unwrap(); + + purgatory2.add_state(event2.clone(), "repo2".to_string(), keys.public_key()); + + // Verify both old and new events work + let (state_count, _) = purgatory2.count(); + assert_eq!(state_count, 2); + + let repo1_states = purgatory2.find_state("repo1"); + assert_eq!(repo1_states.len(), 1); + assert_eq!(repo1_states[0].event.id, event1.id); + + let repo2_states = purgatory2.find_state("repo2"); + assert_eq!(repo2_states.len(), 1); + assert_eq!(repo2_states[0].event.id, event2.id); + + // Verify cleanup still works + let (state_removed, pr_removed) = purgatory2.cleanup(); + // Nothing should be expired yet + assert_eq!(state_removed, 0); + assert_eq!(pr_removed, 0); +} + +/// Test 15: Verify rejected cache continues working after restore +#[tokio::test] +async fn test_rejected_cache_continues_working_after_restore() { + let temp_dir = tempfile::tempdir().unwrap(); + let state_path = temp_dir.path().join("rejected_cache.json"); + + let index = RejectedEventsIndex::new(Duration::from_secs(120), Duration::from_secs(604800)); + let keys = Keys::generate(); + + let event1 = create_test_event(&keys, "event1").await; + + index.add_announcement( + event1.clone(), + event1.pubkey, + "repo1".to_string(), + RejectionReason::DoesNotListService, + ); + + // Save and restore + index.save_to_disk(&state_path).unwrap(); + + let index2 = RejectedEventsIndex::new(Duration::from_secs(120), Duration::from_secs(604800)); + index2.restore_from_disk(&state_path).unwrap(); + + // Add new events after restore + let event2 = create_test_event(&keys, "event2").await; + + index2.add_announcement( + event2.clone(), + event2.pubkey, + "repo2".to_string(), + RejectionReason::MaintainerNotYetValid, + ); + + // Verify both old and new events work + assert_eq!(index2.hot_cache_len(), 2); + assert_eq!(index2.cold_index_len(), 2); + assert!(index2.contains(&event1.id)); + assert!(index2.contains(&event2.id)); + + // Verify invalidation still works + let (removed, hot_events) = + index2.invalidate_and_get(&event1.pubkey, "repo1", Some(EventType::Announcement)); + assert_eq!(removed, 1); + assert_eq!(hot_events.len(), 1); + assert_eq!(hot_events[0].id, event1.id); +} + +/// Test 16: Entries that expired during downtime are properly handled +#[tokio::test] +async fn test_purgatory_entries_expired_during_downtime() { + let temp_dir = tempfile::tempdir().unwrap(); + let git_data_path = temp_dir.path().join("git"); + let state_path = temp_dir.path().join("purgatory.json"); + + let purgatory = Purgatory::new(&git_data_path); + let keys = Keys::generate(); + + let event = create_state_event_with_refs(&keys, "repo1", &[("main", "abc123")]) + .unwrap(); + + purgatory.add_state(event.clone(), "repo1".to_string(), keys.public_key()); + + // Save to disk + purgatory.save_to_disk(&state_path).unwrap(); + + // Simulate very long downtime (longer than the 30min default expiry) + // Note: We can't manually set expiry without accessing private fields, + // so this test verifies that the system handles already-expired entries gracefully + // In a real scenario, if downtime > 30 minutes, entries would be expired on restore + + // For this test, we'll just verify the restore works and cleanup can be called + let purgatory2 = Purgatory::new(&git_data_path); + purgatory2.restore_from_disk(&state_path).unwrap(); + + // Event should be restored + let (state_count, _) = purgatory2.count(); + assert_eq!(state_count, 1); + + // Cleanup should work (even if nothing is expired yet) + let (state_removed, _) = purgatory2.cleanup(); + // Nothing expired yet since we didn't wait 30 minutes + assert_eq!(state_removed, 0); + + let (state_count, _) = purgatory2.count(); + assert_eq!(state_count, 1); +} + +/// Test 17: Rejected cache entries that expired during downtime +#[tokio::test] +async fn test_rejected_cache_entries_expired_during_downtime() { + let temp_dir = tempfile::tempdir().unwrap(); + let state_path = temp_dir.path().join("rejected_cache.json"); + + // Create index with very short expiry + let index = RejectedEventsIndex::new( + Duration::from_millis(50), // Hot cache: 50ms + Duration::from_millis(100), // Cold index: 100ms + ); + let keys = Keys::generate(); + + let event = create_test_event(&keys, "test").await; + + index.add_announcement( + event.clone(), + event.pubkey, + "repo1".to_string(), + RejectionReason::DoesNotListService, + ); + + // Save to disk + index.save_to_disk(&state_path).unwrap(); + + // Simulate downtime longer than hot cache expiry + tokio::time::sleep(Duration::from_millis(75)).await; + + // Restore + let index2 = RejectedEventsIndex::new(Duration::from_millis(50), Duration::from_millis(100)); + index2.restore_from_disk(&state_path).unwrap(); + + // Both should be restored initially + assert_eq!(index2.hot_cache_len(), 1); + assert_eq!(index2.cold_index_len(), 1); + + // Note: We can't directly access hot_cache.get_maintainer_events (private method) + // But we can verify the entry is there via contains() and test cleanup + + // Verify entry is still tracked + assert!(index2.contains(&event.id)); + + // Cleanup should remove expired hot cache entry + let (hot_expired, cold_expired) = index2.cleanup_expired_for_type("announcement"); + assert_eq!(hot_expired, 1); + assert_eq!(cold_expired, 0); // Cold index still valid + + assert_eq!(index2.hot_cache_len(), 0); + assert_eq!(index2.cold_index_len(), 1); +} -- cgit v1.2.3