Files
ngit-grasp/tests/purgatory_persistence.rs
T
DanConwayDev 1d09e4bdea feat: implement announcement purgatory core (breaks archive sync test)
Route new announcements to purgatory instead of accepting immediately.
Announcements are promoted to the database when git data arrives,
ensuring we only serve announcements for repos with actual content.

Implemented:
- AnnouncementPurgatoryEntry type and DashMap store
- Route new announcements to purgatory (replacement announcements skip)
- Promote announcements on git data arrival (process_purgatory_announcements)
- Authorization checks purgatory announcements (fetch_repository_data_with_purgatory)
- State policy uses purgatory announcements for maintainer validation
- Cleanup task handles announcement expiry
- Updated count()/cleanup() to 3-tuples

Known broken:
- test_archive_read_only_creates_bare_repo fails: sync module does not
  treat purgatory announcements as confirmed repos, so per-repo sync
  (state events, PRs) is never triggered for purgatory announcements
- Announcement persistence (save/restore) not implemented
- SyncLevel (StateOnly vs Full) not implemented
- Soft expiry two-phase not implemented
- Expiry extension on state event / git auth not wired up
2026-02-13 17:29:23 +00:00

751 lines
25 KiB
Rust

//! 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<Event, Box<dyn std::error::Error>> {
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 (announcement_count, state_count, pr_count) = purgatory.count();
assert_eq!(announcement_count, 0, "Should have 0 announcements");
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 (announcement_count2, state_count2, pr_count2) = purgatory2.count();
assert_eq!(announcement_count2, 0, "Should have 0 announcements after restore");
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);
}