diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2026-01-14 10:46:30 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2026-01-14 10:46:30 +0000 |
| commit | 4c8f1813fada9ce2bfd371095b0721bff68173e3 (patch) | |
| tree | d42869f89e4916bb8dc36fd26c9ac5f888e042ac /tests | |
| parent | 7dba18eb9ae64d429fef1a1f5437981efefb86b6 (diff) | |
| parent | b101afa00bc28e1b55286145cb81e32a5b3decc9 (diff) | |
Add purgatory persistence to survive relay restarts
Implement save/restore functionality for both purgatory state and
rejected events cache. Events are now saved to disk on graceful
shutdown and restored on startup, preventing data loss during
relay restarts.
Key features:
- Purgatory state persisted to JSON (state events, PR events, expired events)
- Rejected events cache persisted (hot cache + cold index)
- Downtime adjustment preserves remaining TTL
- Graceful degradation on missing/corrupted files
- Automatic re-queueing of restored repositories
- Comprehensive test coverage (45 tests)
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/purgatory_persistence.rs | 755 |
1 files changed, 755 insertions, 0 deletions
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 @@ | |||
| 1 | //! Purgatory Persistence Integration Tests | ||
| 2 | //! | ||
| 3 | //! Tests that verify the full purgatory persistence save/restore cycle: | ||
| 4 | //! - Purgatory save/restore with state events, PR events, and expired events | ||
| 5 | //! - Rejected cache save/restore with hot cache and cold index entries | ||
| 6 | //! - Integration with shutdown/startup hooks | ||
| 7 | //! - Graceful degradation with missing or corrupted files | ||
| 8 | //! - Time adjustment for downtime | ||
| 9 | //! | ||
| 10 | //! # Test Strategy | ||
| 11 | //! | ||
| 12 | //! These tests verify end-to-end persistence functionality: | ||
| 13 | //! 1. Create purgatory/rejected cache instances with various entries | ||
| 14 | //! 2. Save state to disk | ||
| 15 | //! 3. Create new instances and restore from disk | ||
| 16 | //! 4. Verify all data is restored correctly | ||
| 17 | //! 5. Verify system continues to work after restore | ||
| 18 | //! | ||
| 19 | //! # Running Tests | ||
| 20 | //! | ||
| 21 | //! ```bash | ||
| 22 | //! # Run all purgatory persistence tests | ||
| 23 | //! cargo test --test purgatory_persistence | ||
| 24 | //! | ||
| 25 | //! # Run specific test | ||
| 26 | //! cargo test --test purgatory_persistence test_full_purgatory_save_restore_cycle | ||
| 27 | //! | ||
| 28 | //! # With output for debugging | ||
| 29 | //! cargo test --test purgatory_persistence -- --nocapture | ||
| 30 | //! ``` | ||
| 31 | |||
| 32 | mod common; | ||
| 33 | |||
| 34 | use ngit_grasp::purgatory::Purgatory; | ||
| 35 | use ngit_grasp::sync::rejected_index::{EventType, RejectedEventsIndex, RejectionReason}; | ||
| 36 | use nostr_sdk::prelude::*; | ||
| 37 | use std::time::Duration; | ||
| 38 | |||
| 39 | /// Helper to create a test event | ||
| 40 | async fn create_test_event(keys: &Keys, content: &str) -> Event { | ||
| 41 | EventBuilder::text_note(content) | ||
| 42 | .sign_with_keys(keys) | ||
| 43 | .unwrap() | ||
| 44 | } | ||
| 45 | |||
| 46 | /// Helper to create a state event with specific refs | ||
| 47 | fn create_state_event_with_refs( | ||
| 48 | keys: &Keys, | ||
| 49 | identifier: &str, | ||
| 50 | refs: &[(&str, &str)], | ||
| 51 | ) -> Result<Event, Box<dyn std::error::Error>> { | ||
| 52 | let mut tags = vec![Tag::identifier(identifier)]; | ||
| 53 | |||
| 54 | // Add ref tags | ||
| 55 | for (ref_name, commit_hash) in refs { | ||
| 56 | tags.push(Tag::custom( | ||
| 57 | TagKind::custom("ref"), | ||
| 58 | vec![ref_name.to_string(), commit_hash.to_string()], | ||
| 59 | )); | ||
| 60 | } | ||
| 61 | |||
| 62 | let event = EventBuilder::new(Kind::from(30618), "") | ||
| 63 | .tags(tags) | ||
| 64 | .sign_with_keys(keys)?; | ||
| 65 | |||
| 66 | Ok(event) | ||
| 67 | } | ||
| 68 | |||
| 69 | /// Test 1: Full save/restore cycle with state events, PR events, and expired events | ||
| 70 | #[tokio::test] | ||
| 71 | async fn test_full_purgatory_save_restore_cycle() { | ||
| 72 | let temp_dir = tempfile::tempdir().unwrap(); | ||
| 73 | let git_data_path = temp_dir.path().join("git"); | ||
| 74 | let state_path = temp_dir.path().join("purgatory.json"); | ||
| 75 | |||
| 76 | // Create purgatory instance | ||
| 77 | let purgatory = Purgatory::new(&git_data_path); | ||
| 78 | |||
| 79 | // Create test keys and events | ||
| 80 | let keys1 = Keys::generate(); | ||
| 81 | let keys2 = Keys::generate(); | ||
| 82 | let keys3 = Keys::generate(); | ||
| 83 | |||
| 84 | let state_event1 = | ||
| 85 | create_state_event_with_refs(&keys1, "repo1", &[("main", "abc123")]).unwrap(); | ||
| 86 | let state_event2 = | ||
| 87 | create_state_event_with_refs(&keys2, "repo2", &[("main", "def456")]).unwrap(); | ||
| 88 | |||
| 89 | let pr_event1 = create_test_event(&keys3, "PR 1").await; | ||
| 90 | let pr_event2 = create_test_event(&keys3, "PR 2").await; | ||
| 91 | |||
| 92 | // Add state events to purgatory | ||
| 93 | purgatory.add_state( | ||
| 94 | state_event1.clone(), | ||
| 95 | "repo1".to_string(), | ||
| 96 | keys1.public_key(), | ||
| 97 | ); | ||
| 98 | purgatory.add_state( | ||
| 99 | state_event2.clone(), | ||
| 100 | "repo2".to_string(), | ||
| 101 | keys2.public_key(), | ||
| 102 | ); | ||
| 103 | |||
| 104 | // Add PR events to purgatory | ||
| 105 | purgatory.add_pr( | ||
| 106 | pr_event1.clone(), | ||
| 107 | pr_event1.id.to_hex(), | ||
| 108 | "commit-abc".to_string(), | ||
| 109 | ); | ||
| 110 | purgatory.add_pr( | ||
| 111 | pr_event2.clone(), | ||
| 112 | pr_event2.id.to_hex(), | ||
| 113 | "commit-def".to_string(), | ||
| 114 | ); | ||
| 115 | |||
| 116 | // Add a PR placeholder (git-data-first scenario) | ||
| 117 | purgatory.add_pr_placeholder("placeholder-id".to_string(), "commit-xyz".to_string()); | ||
| 118 | |||
| 119 | // Note: We can't directly test expired events without accessing private fields, | ||
| 120 | // so we'll focus on testing state and PR events persistence | ||
| 121 | |||
| 122 | // Verify initial counts | ||
| 123 | let (state_count, pr_count) = purgatory.count(); | ||
| 124 | assert_eq!(state_count, 2, "Should have 2 state events"); | ||
| 125 | assert_eq!( | ||
| 126 | pr_count, 3, | ||
| 127 | "Should have 3 PR events (2 events + 1 placeholder)" | ||
| 128 | ); | ||
| 129 | |||
| 130 | // Save to disk | ||
| 131 | purgatory.save_to_disk(&state_path).unwrap(); | ||
| 132 | assert!(state_path.exists(), "State file should exist after save"); | ||
| 133 | |||
| 134 | // Create new purgatory instance and restore | ||
| 135 | let purgatory2 = Purgatory::new(&git_data_path); | ||
| 136 | purgatory2.restore_from_disk(&state_path).unwrap(); | ||
| 137 | |||
| 138 | // Verify state file was deleted after restore | ||
| 139 | assert!( | ||
| 140 | !state_path.exists(), | ||
| 141 | "State file should be deleted after restore" | ||
| 142 | ); | ||
| 143 | |||
| 144 | // Verify all data was restored | ||
| 145 | let (state_count2, pr_count2) = purgatory2.count(); | ||
| 146 | assert_eq!(state_count2, 2, "Should have 2 state events after restore"); | ||
| 147 | assert_eq!( | ||
| 148 | pr_count2, 3, | ||
| 149 | "Should have 3 PR events after restore (2 events + 1 placeholder)" | ||
| 150 | ); | ||
| 151 | |||
| 152 | // Verify specific state events | ||
| 153 | let repo1_states = purgatory2.find_state("repo1"); | ||
| 154 | assert_eq!(repo1_states.len(), 1); | ||
| 155 | assert_eq!(repo1_states[0].event.id, state_event1.id); | ||
| 156 | |||
| 157 | let repo2_states = purgatory2.find_state("repo2"); | ||
| 158 | assert_eq!(repo2_states.len(), 1); | ||
| 159 | assert_eq!(repo2_states[0].event.id, state_event2.id); | ||
| 160 | |||
| 161 | // Verify PR events | ||
| 162 | let pr1 = purgatory2.find_pr(&pr_event1.id.to_hex()); | ||
| 163 | assert!(pr1.is_some()); | ||
| 164 | assert_eq!(pr1.unwrap().commit, "commit-abc"); | ||
| 165 | |||
| 166 | let pr2 = purgatory2.find_pr(&pr_event2.id.to_hex()); | ||
| 167 | assert!(pr2.is_some()); | ||
| 168 | assert_eq!(pr2.unwrap().commit, "commit-def"); | ||
| 169 | |||
| 170 | // Verify placeholder | ||
| 171 | let placeholder = purgatory2.find_pr_placeholder("placeholder-id"); | ||
| 172 | assert_eq!(placeholder, Some("commit-xyz".to_string())); | ||
| 173 | |||
| 174 | // Verify re-queueing works - get all identifiers | ||
| 175 | let identifiers = purgatory2.get_all_identifiers(); | ||
| 176 | assert_eq!(identifiers.len(), 2); | ||
| 177 | assert!(identifiers.contains(&"repo1".to_string())); | ||
| 178 | assert!(identifiers.contains(&"repo2".to_string())); | ||
| 179 | } | ||
| 180 | |||
| 181 | /// Test 2: Rejected cache integration - save/restore hot cache and cold index | ||
| 182 | #[tokio::test] | ||
| 183 | async fn test_rejected_cache_save_restore_cycle() { | ||
| 184 | let temp_dir = tempfile::tempdir().unwrap(); | ||
| 185 | let state_path = temp_dir.path().join("rejected_cache.json"); | ||
| 186 | |||
| 187 | // Create rejected events index | ||
| 188 | let index = RejectedEventsIndex::new(Duration::from_secs(120), Duration::from_secs(604800)); | ||
| 189 | |||
| 190 | // Create test events | ||
| 191 | let keys1 = Keys::generate(); | ||
| 192 | let keys2 = Keys::generate(); | ||
| 193 | |||
| 194 | let event1 = create_test_event(&keys1, "announcement 1").await; | ||
| 195 | let event2 = create_test_event(&keys2, "announcement 2").await; | ||
| 196 | let event3 = create_test_event(&keys1, "state 1").await; | ||
| 197 | |||
| 198 | // Add announcements to rejected cache | ||
| 199 | index.add_announcement( | ||
| 200 | event1.clone(), | ||
| 201 | event1.pubkey, | ||
| 202 | "repo1".to_string(), | ||
| 203 | RejectionReason::DoesNotListService, | ||
| 204 | ); | ||
| 205 | |||
| 206 | index.add_announcement( | ||
| 207 | event2.clone(), | ||
| 208 | event2.pubkey, | ||
| 209 | "repo2".to_string(), | ||
| 210 | RejectionReason::MaintainerNotYetValid, | ||
| 211 | ); | ||
| 212 | |||
| 213 | // Add state event to rejected cache | ||
| 214 | index.add_state( | ||
| 215 | event3.clone(), | ||
| 216 | event3.pubkey, | ||
| 217 | "repo1".to_string(), | ||
| 218 | RejectionReason::Other, | ||
| 219 | ); | ||
| 220 | |||
| 221 | // Verify initial counts | ||
| 222 | assert_eq!(index.hot_cache_len(), 3); | ||
| 223 | assert_eq!(index.cold_index_len(), 3); | ||
| 224 | |||
| 225 | // Save to disk | ||
| 226 | index.save_to_disk(&state_path).unwrap(); | ||
| 227 | assert!(state_path.exists()); | ||
| 228 | |||
| 229 | // Create new index and restore | ||
| 230 | let index2 = RejectedEventsIndex::new(Duration::from_secs(120), Duration::from_secs(604800)); | ||
| 231 | index2.restore_from_disk(&state_path).unwrap(); | ||
| 232 | |||
| 233 | // Verify state file was deleted | ||
| 234 | assert!(!state_path.exists()); | ||
| 235 | |||
| 236 | // Verify all entries restored | ||
| 237 | assert_eq!(index2.hot_cache_len(), 3); | ||
| 238 | assert_eq!(index2.cold_index_len(), 3); | ||
| 239 | |||
| 240 | // Verify specific entries | ||
| 241 | assert!(index2.contains(&event1.id)); | ||
| 242 | assert!(index2.contains(&event2.id)); | ||
| 243 | assert!(index2.contains(&event3.id)); | ||
| 244 | |||
| 245 | // Verify we can invalidate and get events | ||
| 246 | let (removed, hot_events) = | ||
| 247 | index2.invalidate_and_get(&event1.pubkey, "repo1", Some(EventType::Announcement)); | ||
| 248 | assert_eq!(removed, 1); | ||
| 249 | assert_eq!(hot_events.len(), 1); | ||
| 250 | assert_eq!(hot_events[0].id, event1.id); | ||
| 251 | } | ||
| 252 | |||
| 253 | /// Test 3: Simulated downtime - verify expiry times are adjusted correctly | ||
| 254 | #[tokio::test] | ||
| 255 | async fn test_purgatory_downtime_adjustment() { | ||
| 256 | let temp_dir = tempfile::tempdir().unwrap(); | ||
| 257 | let git_data_path = temp_dir.path().join("git"); | ||
| 258 | let state_path = temp_dir.path().join("purgatory.json"); | ||
| 259 | |||
| 260 | let purgatory = Purgatory::new(&git_data_path); | ||
| 261 | let keys = Keys::generate(); | ||
| 262 | |||
| 263 | let state_event = create_state_event_with_refs(&keys, "repo1", &[("main", "abc123")]) | ||
| 264 | .unwrap(); | ||
| 265 | |||
| 266 | purgatory.add_state(state_event.clone(), "repo1".to_string(), keys.public_key()); | ||
| 267 | |||
| 268 | // Save to disk | ||
| 269 | purgatory.save_to_disk(&state_path).unwrap(); | ||
| 270 | |||
| 271 | // Simulate downtime | ||
| 272 | tokio::time::sleep(Duration::from_millis(100)).await; | ||
| 273 | |||
| 274 | // Restore | ||
| 275 | let purgatory2 = Purgatory::new(&git_data_path); | ||
| 276 | purgatory2.restore_from_disk(&state_path).unwrap(); | ||
| 277 | |||
| 278 | // Verify event is still there (downtime was accounted for) | ||
| 279 | let (state_count, _) = purgatory2.count(); | ||
| 280 | assert_eq!(state_count, 1); | ||
| 281 | |||
| 282 | let repo1_states = purgatory2.find_state("repo1"); | ||
| 283 | assert_eq!(repo1_states.len(), 1); | ||
| 284 | assert_eq!(repo1_states[0].event.id, state_event.id); | ||
| 285 | |||
| 286 | // Verify the event hasn't expired yet (expiry time was adjusted) | ||
| 287 | // The event should have ~30 minutes minus the downtime | ||
| 288 | let entry = &repo1_states[0]; | ||
| 289 | let remaining = entry | ||
| 290 | .expires_at | ||
| 291 | .saturating_duration_since(std::time::Instant::now()); | ||
| 292 | assert!( | ||
| 293 | remaining > Duration::from_secs(1700), | ||
| 294 | "Event should have most of its 30min expiry remaining" | ||
| 295 | ); | ||
| 296 | } | ||
| 297 | |||
| 298 | /// Test 4: Rejected cache downtime adjustment | ||
| 299 | #[tokio::test] | ||
| 300 | async fn test_rejected_cache_downtime_adjustment() { | ||
| 301 | let temp_dir = tempfile::tempdir().unwrap(); | ||
| 302 | let state_path = temp_dir.path().join("rejected_cache.json"); | ||
| 303 | |||
| 304 | let index = RejectedEventsIndex::new(Duration::from_secs(120), Duration::from_secs(604800)); | ||
| 305 | let keys = Keys::generate(); | ||
| 306 | |||
| 307 | let event = create_test_event(&keys, "test").await; | ||
| 308 | |||
| 309 | index.add_announcement( | ||
| 310 | event.clone(), | ||
| 311 | event.pubkey, | ||
| 312 | "repo1".to_string(), | ||
| 313 | RejectionReason::DoesNotListService, | ||
| 314 | ); | ||
| 315 | |||
| 316 | // Save to disk | ||
| 317 | index.save_to_disk(&state_path).unwrap(); | ||
| 318 | |||
| 319 | // Simulate downtime | ||
| 320 | tokio::time::sleep(Duration::from_millis(100)).await; | ||
| 321 | |||
| 322 | // Restore | ||
| 323 | let index2 = RejectedEventsIndex::new(Duration::from_secs(120), Duration::from_secs(604800)); | ||
| 324 | index2.restore_from_disk(&state_path).unwrap(); | ||
| 325 | |||
| 326 | // Verify event is still in both caches (downtime was accounted for) | ||
| 327 | assert_eq!(index2.hot_cache_len(), 1); | ||
| 328 | assert_eq!(index2.cold_index_len(), 1); | ||
| 329 | assert!(index2.contains(&event.id)); | ||
| 330 | } | ||
| 331 | |||
| 332 | /// Test 5: File cleanup - verify state files are deleted after successful restore | ||
| 333 | #[tokio::test] | ||
| 334 | async fn test_purgatory_file_cleanup_after_restore() { | ||
| 335 | let temp_dir = tempfile::tempdir().unwrap(); | ||
| 336 | let git_data_path = temp_dir.path().join("git"); | ||
| 337 | let state_path = temp_dir.path().join("purgatory.json"); | ||
| 338 | |||
| 339 | let purgatory = Purgatory::new(&git_data_path); | ||
| 340 | let keys = Keys::generate(); | ||
| 341 | |||
| 342 | let state_event = create_state_event_with_refs(&keys, "repo1", &[("main", "abc123")]) | ||
| 343 | .unwrap(); | ||
| 344 | |||
| 345 | purgatory.add_state(state_event, "repo1".to_string(), keys.public_key()); | ||
| 346 | |||
| 347 | // Save to disk | ||
| 348 | purgatory.save_to_disk(&state_path).unwrap(); | ||
| 349 | assert!(state_path.exists(), "State file should exist after save"); | ||
| 350 | |||
| 351 | // Restore | ||
| 352 | let purgatory2 = Purgatory::new(&git_data_path); | ||
| 353 | purgatory2.restore_from_disk(&state_path).unwrap(); | ||
| 354 | |||
| 355 | // Verify file was deleted | ||
| 356 | assert!( | ||
| 357 | !state_path.exists(), | ||
| 358 | "State file should be deleted after successful restore" | ||
| 359 | ); | ||
| 360 | } | ||
| 361 | |||
| 362 | /// Test 6: Rejected cache file cleanup | ||
| 363 | #[tokio::test] | ||
| 364 | async fn test_rejected_cache_file_cleanup_after_restore() { | ||
| 365 | let temp_dir = tempfile::tempdir().unwrap(); | ||
| 366 | let state_path = temp_dir.path().join("rejected_cache.json"); | ||
| 367 | |||
| 368 | let index = RejectedEventsIndex::new(Duration::from_secs(120), Duration::from_secs(604800)); | ||
| 369 | let keys = Keys::generate(); | ||
| 370 | |||
| 371 | let event = create_test_event(&keys, "test").await; | ||
| 372 | |||
| 373 | index.add_announcement( | ||
| 374 | event, | ||
| 375 | keys.public_key(), | ||
| 376 | "repo1".to_string(), | ||
| 377 | RejectionReason::DoesNotListService, | ||
| 378 | ); | ||
| 379 | |||
| 380 | // Save to disk | ||
| 381 | index.save_to_disk(&state_path).unwrap(); | ||
| 382 | assert!(state_path.exists()); | ||
| 383 | |||
| 384 | // Restore | ||
| 385 | let index2 = RejectedEventsIndex::new(Duration::from_secs(120), Duration::from_secs(604800)); | ||
| 386 | index2.restore_from_disk(&state_path).unwrap(); | ||
| 387 | |||
| 388 | // Verify file was deleted | ||
| 389 | assert!(!state_path.exists()); | ||
| 390 | } | ||
| 391 | |||
| 392 | /// Test 7: Graceful degradation - missing purgatory file | ||
| 393 | #[tokio::test] | ||
| 394 | async fn test_purgatory_restore_missing_file() { | ||
| 395 | let temp_dir = tempfile::tempdir().unwrap(); | ||
| 396 | let git_data_path = temp_dir.path().join("git"); | ||
| 397 | let state_path = temp_dir.path().join("nonexistent.json"); | ||
| 398 | |||
| 399 | let purgatory = Purgatory::new(&git_data_path); | ||
| 400 | |||
| 401 | // Attempting to restore missing file should return error | ||
| 402 | let result = purgatory.restore_from_disk(&state_path); | ||
| 403 | assert!(result.is_err(), "Should error on missing file"); | ||
| 404 | |||
| 405 | // Purgatory should still be usable (empty state) | ||
| 406 | let (state_count, pr_count) = purgatory.count(); | ||
| 407 | assert_eq!(state_count, 0); | ||
| 408 | assert_eq!(pr_count, 0); | ||
| 409 | |||
| 410 | // Should be able to add events normally | ||
| 411 | let keys = Keys::generate(); | ||
| 412 | let event = create_test_event(&keys, "test").await; | ||
| 413 | purgatory.add_state(event, "repo1".to_string(), keys.public_key()); | ||
| 414 | |||
| 415 | let (state_count, _) = purgatory.count(); | ||
| 416 | assert_eq!(state_count, 1); | ||
| 417 | } | ||
| 418 | |||
| 419 | /// Test 8: Graceful degradation - missing rejected cache file | ||
| 420 | #[tokio::test] | ||
| 421 | async fn test_rejected_cache_restore_missing_file() { | ||
| 422 | let temp_dir = tempfile::tempdir().unwrap(); | ||
| 423 | let state_path = temp_dir.path().join("nonexistent.json"); | ||
| 424 | |||
| 425 | let index = RejectedEventsIndex::new(Duration::from_secs(120), Duration::from_secs(604800)); | ||
| 426 | |||
| 427 | // Attempting to restore missing file should return error | ||
| 428 | let result = index.restore_from_disk(&state_path); | ||
| 429 | assert!(result.is_err()); | ||
| 430 | |||
| 431 | // Index should still be usable (empty state) | ||
| 432 | assert_eq!(index.hot_cache_len(), 0); | ||
| 433 | assert_eq!(index.cold_index_len(), 0); | ||
| 434 | |||
| 435 | // Should be able to add events normally | ||
| 436 | let keys = Keys::generate(); | ||
| 437 | let event = create_test_event(&keys, "test").await; | ||
| 438 | index.add_announcement( | ||
| 439 | event, | ||
| 440 | keys.public_key(), | ||
| 441 | "repo1".to_string(), | ||
| 442 | RejectionReason::DoesNotListService, | ||
| 443 | ); | ||
| 444 | |||
| 445 | assert_eq!(index.hot_cache_len(), 1); | ||
| 446 | assert_eq!(index.cold_index_len(), 1); | ||
| 447 | } | ||
| 448 | |||
| 449 | /// Test 9: Graceful degradation - corrupted purgatory file | ||
| 450 | #[tokio::test] | ||
| 451 | async fn test_purgatory_restore_corrupted_file() { | ||
| 452 | let temp_dir = tempfile::tempdir().unwrap(); | ||
| 453 | let git_data_path = temp_dir.path().join("git"); | ||
| 454 | let state_path = temp_dir.path().join("corrupted.json"); | ||
| 455 | |||
| 456 | // Write corrupted JSON | ||
| 457 | std::fs::write(&state_path, "{ invalid json !!!").unwrap(); | ||
| 458 | |||
| 459 | let purgatory = Purgatory::new(&git_data_path); | ||
| 460 | |||
| 461 | // Attempting to restore corrupted file should return error | ||
| 462 | let result = purgatory.restore_from_disk(&state_path); | ||
| 463 | assert!(result.is_err(), "Should error on corrupted file"); | ||
| 464 | |||
| 465 | // Purgatory should still be usable | ||
| 466 | let (state_count, pr_count) = purgatory.count(); | ||
| 467 | assert_eq!(state_count, 0); | ||
| 468 | assert_eq!(pr_count, 0); | ||
| 469 | } | ||
| 470 | |||
| 471 | /// Test 10: Graceful degradation - corrupted rejected cache file | ||
| 472 | #[tokio::test] | ||
| 473 | async fn test_rejected_cache_restore_corrupted_file() { | ||
| 474 | let temp_dir = tempfile::tempdir().unwrap(); | ||
| 475 | let state_path = temp_dir.path().join("corrupted.json"); | ||
| 476 | |||
| 477 | // Write corrupted JSON | ||
| 478 | std::fs::write(&state_path, "{ invalid json !!!").unwrap(); | ||
| 479 | |||
| 480 | let index = RejectedEventsIndex::new(Duration::from_secs(120), Duration::from_secs(604800)); | ||
| 481 | |||
| 482 | // Attempting to restore corrupted file should return error | ||
| 483 | let result = index.restore_from_disk(&state_path); | ||
| 484 | assert!(result.is_err()); | ||
| 485 | |||
| 486 | // Index should still be usable | ||
| 487 | assert_eq!(index.hot_cache_len(), 0); | ||
| 488 | assert_eq!(index.cold_index_len(), 0); | ||
| 489 | } | ||
| 490 | |||
| 491 | /// Test 11: Empty purgatory save/restore | ||
| 492 | #[tokio::test] | ||
| 493 | async fn test_empty_purgatory_save_restore() { | ||
| 494 | let temp_dir = tempfile::tempdir().unwrap(); | ||
| 495 | let git_data_path = temp_dir.path().join("git"); | ||
| 496 | let state_path = temp_dir.path().join("purgatory.json"); | ||
| 497 | |||
| 498 | let purgatory = Purgatory::new(&git_data_path); | ||
| 499 | |||
| 500 | // Save empty purgatory | ||
| 501 | purgatory.save_to_disk(&state_path).unwrap(); | ||
| 502 | assert!(state_path.exists()); | ||
| 503 | |||
| 504 | // Restore | ||
| 505 | let purgatory2 = Purgatory::new(&git_data_path); | ||
| 506 | purgatory2.restore_from_disk(&state_path).unwrap(); | ||
| 507 | |||
| 508 | // Verify empty state | ||
| 509 | let (state_count, pr_count) = purgatory2.count(); | ||
| 510 | assert_eq!(state_count, 0); | ||
| 511 | assert_eq!(pr_count, 0); | ||
| 512 | assert_eq!(purgatory2.expired_count(), 0); | ||
| 513 | } | ||
| 514 | |||
| 515 | /// Test 12: Empty rejected cache save/restore | ||
| 516 | #[tokio::test] | ||
| 517 | async fn test_empty_rejected_cache_save_restore() { | ||
| 518 | let temp_dir = tempfile::tempdir().unwrap(); | ||
| 519 | let state_path = temp_dir.path().join("rejected_cache.json"); | ||
| 520 | |||
| 521 | let index = RejectedEventsIndex::new(Duration::from_secs(120), Duration::from_secs(604800)); | ||
| 522 | |||
| 523 | // Save empty cache | ||
| 524 | index.save_to_disk(&state_path).unwrap(); | ||
| 525 | assert!(state_path.exists()); | ||
| 526 | |||
| 527 | // Restore | ||
| 528 | let index2 = RejectedEventsIndex::new(Duration::from_secs(120), Duration::from_secs(604800)); | ||
| 529 | index2.restore_from_disk(&state_path).unwrap(); | ||
| 530 | |||
| 531 | // Verify empty state | ||
| 532 | assert_eq!(index2.hot_cache_len(), 0); | ||
| 533 | assert_eq!(index2.cold_index_len(), 0); | ||
| 534 | } | ||
| 535 | |||
| 536 | /// Test 13: Multiple state events for same identifier | ||
| 537 | #[tokio::test] | ||
| 538 | async fn test_purgatory_multiple_state_events_same_identifier() { | ||
| 539 | let temp_dir = tempfile::tempdir().unwrap(); | ||
| 540 | let git_data_path = temp_dir.path().join("git"); | ||
| 541 | let state_path = temp_dir.path().join("purgatory.json"); | ||
| 542 | |||
| 543 | let purgatory = Purgatory::new(&git_data_path); | ||
| 544 | |||
| 545 | // Create multiple state events for same identifier (different maintainers) | ||
| 546 | let keys1 = Keys::generate(); | ||
| 547 | let keys2 = Keys::generate(); | ||
| 548 | |||
| 549 | let event1 = create_state_event_with_refs(&keys1, "repo1", &[("main", "abc123")]) | ||
| 550 | .unwrap(); | ||
| 551 | let event2 = create_state_event_with_refs(&keys2, "repo1", &[("main", "def456")]) | ||
| 552 | .unwrap(); | ||
| 553 | |||
| 554 | purgatory.add_state(event1.clone(), "repo1".to_string(), keys1.public_key()); | ||
| 555 | purgatory.add_state(event2.clone(), "repo1".to_string(), keys2.public_key()); | ||
| 556 | |||
| 557 | // Save and restore | ||
| 558 | purgatory.save_to_disk(&state_path).unwrap(); | ||
| 559 | |||
| 560 | let purgatory2 = Purgatory::new(&git_data_path); | ||
| 561 | purgatory2.restore_from_disk(&state_path).unwrap(); | ||
| 562 | |||
| 563 | // Verify both events restored | ||
| 564 | let repo1_states = purgatory2.find_state("repo1"); | ||
| 565 | assert_eq!(repo1_states.len(), 2); | ||
| 566 | |||
| 567 | let event_ids: Vec<_> = repo1_states.iter().map(|e| e.event.id).collect(); | ||
| 568 | assert!(event_ids.contains(&event1.id)); | ||
| 569 | assert!(event_ids.contains(&event2.id)); | ||
| 570 | } | ||
| 571 | |||
| 572 | /// Test 14: Verify system continues to work after restore | ||
| 573 | #[tokio::test] | ||
| 574 | async fn test_purgatory_continues_working_after_restore() { | ||
| 575 | let temp_dir = tempfile::tempdir().unwrap(); | ||
| 576 | let git_data_path = temp_dir.path().join("git"); | ||
| 577 | let state_path = temp_dir.path().join("purgatory.json"); | ||
| 578 | |||
| 579 | let purgatory = Purgatory::new(&git_data_path); | ||
| 580 | let keys = Keys::generate(); | ||
| 581 | |||
| 582 | let event1 = create_state_event_with_refs(&keys, "repo1", &[("main", "abc123")]) | ||
| 583 | .unwrap(); | ||
| 584 | |||
| 585 | purgatory.add_state(event1.clone(), "repo1".to_string(), keys.public_key()); | ||
| 586 | |||
| 587 | // Save and restore | ||
| 588 | purgatory.save_to_disk(&state_path).unwrap(); | ||
| 589 | |||
| 590 | let purgatory2 = Purgatory::new(&git_data_path); | ||
| 591 | purgatory2.restore_from_disk(&state_path).unwrap(); | ||
| 592 | |||
| 593 | // Add new events after restore | ||
| 594 | let event2 = create_state_event_with_refs(&keys, "repo2", &[("main", "xyz789")]) | ||
| 595 | .unwrap(); | ||
| 596 | |||
| 597 | purgatory2.add_state(event2.clone(), "repo2".to_string(), keys.public_key()); | ||
| 598 | |||
| 599 | // Verify both old and new events work | ||
| 600 | let (state_count, _) = purgatory2.count(); | ||
| 601 | assert_eq!(state_count, 2); | ||
| 602 | |||
| 603 | let repo1_states = purgatory2.find_state("repo1"); | ||
| 604 | assert_eq!(repo1_states.len(), 1); | ||
| 605 | assert_eq!(repo1_states[0].event.id, event1.id); | ||
| 606 | |||
| 607 | let repo2_states = purgatory2.find_state("repo2"); | ||
| 608 | assert_eq!(repo2_states.len(), 1); | ||
| 609 | assert_eq!(repo2_states[0].event.id, event2.id); | ||
| 610 | |||
| 611 | // Verify cleanup still works | ||
| 612 | let (state_removed, pr_removed) = purgatory2.cleanup(); | ||
| 613 | // Nothing should be expired yet | ||
| 614 | assert_eq!(state_removed, 0); | ||
| 615 | assert_eq!(pr_removed, 0); | ||
| 616 | } | ||
| 617 | |||
| 618 | /// Test 15: Verify rejected cache continues working after restore | ||
| 619 | #[tokio::test] | ||
| 620 | async fn test_rejected_cache_continues_working_after_restore() { | ||
| 621 | let temp_dir = tempfile::tempdir().unwrap(); | ||
| 622 | let state_path = temp_dir.path().join("rejected_cache.json"); | ||
| 623 | |||
| 624 | let index = RejectedEventsIndex::new(Duration::from_secs(120), Duration::from_secs(604800)); | ||
| 625 | let keys = Keys::generate(); | ||
| 626 | |||
| 627 | let event1 = create_test_event(&keys, "event1").await; | ||
| 628 | |||
| 629 | index.add_announcement( | ||
| 630 | event1.clone(), | ||
| 631 | event1.pubkey, | ||
| 632 | "repo1".to_string(), | ||
| 633 | RejectionReason::DoesNotListService, | ||
| 634 | ); | ||
| 635 | |||
| 636 | // Save and restore | ||
| 637 | index.save_to_disk(&state_path).unwrap(); | ||
| 638 | |||
| 639 | let index2 = RejectedEventsIndex::new(Duration::from_secs(120), Duration::from_secs(604800)); | ||
| 640 | index2.restore_from_disk(&state_path).unwrap(); | ||
| 641 | |||
| 642 | // Add new events after restore | ||
| 643 | let event2 = create_test_event(&keys, "event2").await; | ||
| 644 | |||
| 645 | index2.add_announcement( | ||
| 646 | event2.clone(), | ||
| 647 | event2.pubkey, | ||
| 648 | "repo2".to_string(), | ||
| 649 | RejectionReason::MaintainerNotYetValid, | ||
| 650 | ); | ||
| 651 | |||
| 652 | // Verify both old and new events work | ||
| 653 | assert_eq!(index2.hot_cache_len(), 2); | ||
| 654 | assert_eq!(index2.cold_index_len(), 2); | ||
| 655 | assert!(index2.contains(&event1.id)); | ||
| 656 | assert!(index2.contains(&event2.id)); | ||
| 657 | |||
| 658 | // Verify invalidation still works | ||
| 659 | let (removed, hot_events) = | ||
| 660 | index2.invalidate_and_get(&event1.pubkey, "repo1", Some(EventType::Announcement)); | ||
| 661 | assert_eq!(removed, 1); | ||
| 662 | assert_eq!(hot_events.len(), 1); | ||
| 663 | assert_eq!(hot_events[0].id, event1.id); | ||
| 664 | } | ||
| 665 | |||
| 666 | /// Test 16: Entries that expired during downtime are properly handled | ||
| 667 | #[tokio::test] | ||
| 668 | async fn test_purgatory_entries_expired_during_downtime() { | ||
| 669 | let temp_dir = tempfile::tempdir().unwrap(); | ||
| 670 | let git_data_path = temp_dir.path().join("git"); | ||
| 671 | let state_path = temp_dir.path().join("purgatory.json"); | ||
| 672 | |||
| 673 | let purgatory = Purgatory::new(&git_data_path); | ||
| 674 | let keys = Keys::generate(); | ||
| 675 | |||
| 676 | let event = create_state_event_with_refs(&keys, "repo1", &[("main", "abc123")]) | ||
| 677 | .unwrap(); | ||
| 678 | |||
| 679 | purgatory.add_state(event.clone(), "repo1".to_string(), keys.public_key()); | ||
| 680 | |||
| 681 | // Save to disk | ||
| 682 | purgatory.save_to_disk(&state_path).unwrap(); | ||
| 683 | |||
| 684 | // Simulate very long downtime (longer than the 30min default expiry) | ||
| 685 | // Note: We can't manually set expiry without accessing private fields, | ||
| 686 | // so this test verifies that the system handles already-expired entries gracefully | ||
| 687 | // In a real scenario, if downtime > 30 minutes, entries would be expired on restore | ||
| 688 | |||
| 689 | // For this test, we'll just verify the restore works and cleanup can be called | ||
| 690 | let purgatory2 = Purgatory::new(&git_data_path); | ||
| 691 | purgatory2.restore_from_disk(&state_path).unwrap(); | ||
| 692 | |||
| 693 | // Event should be restored | ||
| 694 | let (state_count, _) = purgatory2.count(); | ||
| 695 | assert_eq!(state_count, 1); | ||
| 696 | |||
| 697 | // Cleanup should work (even if nothing is expired yet) | ||
| 698 | let (state_removed, _) = purgatory2.cleanup(); | ||
| 699 | // Nothing expired yet since we didn't wait 30 minutes | ||
| 700 | assert_eq!(state_removed, 0); | ||
| 701 | |||
| 702 | let (state_count, _) = purgatory2.count(); | ||
| 703 | assert_eq!(state_count, 1); | ||
| 704 | } | ||
| 705 | |||
| 706 | /// Test 17: Rejected cache entries that expired during downtime | ||
| 707 | #[tokio::test] | ||
| 708 | async fn test_rejected_cache_entries_expired_during_downtime() { | ||
| 709 | let temp_dir = tempfile::tempdir().unwrap(); | ||
| 710 | let state_path = temp_dir.path().join("rejected_cache.json"); | ||
| 711 | |||
| 712 | // Create index with very short expiry | ||
| 713 | let index = RejectedEventsIndex::new( | ||
| 714 | Duration::from_millis(50), // Hot cache: 50ms | ||
| 715 | Duration::from_millis(100), // Cold index: 100ms | ||
| 716 | ); | ||
| 717 | let keys = Keys::generate(); | ||
| 718 | |||
| 719 | let event = create_test_event(&keys, "test").await; | ||
| 720 | |||
| 721 | index.add_announcement( | ||
| 722 | event.clone(), | ||
| 723 | event.pubkey, | ||
| 724 | "repo1".to_string(), | ||
| 725 | RejectionReason::DoesNotListService, | ||
| 726 | ); | ||
| 727 | |||
| 728 | // Save to disk | ||
| 729 | index.save_to_disk(&state_path).unwrap(); | ||
| 730 | |||
| 731 | // Simulate downtime longer than hot cache expiry | ||
| 732 | tokio::time::sleep(Duration::from_millis(75)).await; | ||
| 733 | |||
| 734 | // Restore | ||
| 735 | let index2 = RejectedEventsIndex::new(Duration::from_millis(50), Duration::from_millis(100)); | ||
| 736 | index2.restore_from_disk(&state_path).unwrap(); | ||
| 737 | |||
| 738 | // Both should be restored initially | ||
| 739 | assert_eq!(index2.hot_cache_len(), 1); | ||
| 740 | assert_eq!(index2.cold_index_len(), 1); | ||
| 741 | |||
| 742 | // Note: We can't directly access hot_cache.get_maintainer_events (private method) | ||
| 743 | // But we can verify the entry is there via contains() and test cleanup | ||
| 744 | |||
| 745 | // Verify entry is still tracked | ||
| 746 | assert!(index2.contains(&event.id)); | ||
| 747 | |||
| 748 | // Cleanup should remove expired hot cache entry | ||
| 749 | let (hot_expired, cold_expired) = index2.cleanup_expired_for_type("announcement"); | ||
| 750 | assert_eq!(hot_expired, 1); | ||
| 751 | assert_eq!(cold_expired, 0); // Cold index still valid | ||
| 752 | |||
| 753 | assert_eq!(index2.hot_cache_len(), 0); | ||
| 754 | assert_eq!(index2.cold_index_len(), 1); | ||
| 755 | } | ||