upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2026-01-14 10:46:30 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-01-14 10:46:30 +0000
commit4c8f1813fada9ce2bfd371095b0721bff68173e3 (patch)
treed42869f89e4916bb8dc36fd26c9ac5f888e042ac /tests
parent7dba18eb9ae64d429fef1a1f5437981efefb86b6 (diff)
parentb101afa00bc28e1b55286145cb81e32a5b3decc9 (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.rs755
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
32mod common;
33
34use ngit_grasp::purgatory::Purgatory;
35use ngit_grasp::sync::rejected_index::{EventType, RejectedEventsIndex, RejectionReason};
36use nostr_sdk::prelude::*;
37use std::time::Duration;
38
39/// Helper to create a test event
40async 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
47fn 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]
71async 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]
183async 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]
255async 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]
300async 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]
334async 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]
364async 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]
394async 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]
421async 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]
451async 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]
473async 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]
493async 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]
517async 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]
538async 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]
574async 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]
620async 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]
668async 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]
708async 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}