diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2026-02-23 15:08:37 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2026-02-23 15:08:37 +0000 |
| commit | 26f608e5011b9d1ad6036da75b89272835e69695 (patch) | |
| tree | 8b5dfe29f65abe80e59bddbcd3ee09c0a369dba8 /tests | |
| parent | 4848c4029fc58f6f310a2babeae1ee82a7e41656 (diff) | |
persist and restore announcement events across graceful restarts
Extends purgatory persistence to include announcement purgatory entries.
On graceful shutdown, non-soft-expired announcements are serialised to
purgatory-state.json alongside state/PR/expired events; on startup they
are restored, skipping any entry whose bare repo path no longer exists.
Updates purgatory-design.md to reflect that purgatory persists through
graceful shutdown and documents the new PurgatoryState disk format.
Adds create_announcement_event helper to purgatory_helpers and three new
integration tests in purgatory_persistence covering the full save/restore
cycle, missing-repo skip, and the combined roundtrip with all entry types.
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/common/purgatory_helpers.rs | 38 | ||||
| -rw-r--r-- | tests/purgatory_persistence.rs | 135 |
2 files changed, 169 insertions, 4 deletions
diff --git a/tests/common/purgatory_helpers.rs b/tests/common/purgatory_helpers.rs index 1d06f22..cfcea1c 100644 --- a/tests/common/purgatory_helpers.rs +++ b/tests/common/purgatory_helpers.rs | |||
| @@ -338,6 +338,44 @@ pub fn build_repo_coord(keys: &Keys, identifier: &str) -> String { | |||
| 338 | format!("30617:{}:{}", keys.public_key().to_hex(), identifier) | 338 | format!("30617:{}:{}", keys.public_key().to_hex(), identifier) |
| 339 | } | 339 | } |
| 340 | 340 | ||
| 341 | /// Create a repository announcement event (kind 30617) for purgatory tests. | ||
| 342 | /// | ||
| 343 | /// Creates a minimal but valid NIP-34 repository announcement with a `d` tag, | ||
| 344 | /// optional `clone` URLs, and optional `relays` URLs. | ||
| 345 | /// | ||
| 346 | /// # Arguments | ||
| 347 | /// * `keys` - Keys for signing | ||
| 348 | /// * `identifier` - Repository identifier (d-tag) | ||
| 349 | /// * `clone_urls` - Clone URLs to include (may be empty) | ||
| 350 | /// * `relay_urls` - Relay URLs to include (may be empty) | ||
| 351 | /// | ||
| 352 | /// # Returns | ||
| 353 | /// * `Ok(Event)` - Signed announcement event | ||
| 354 | /// * `Err(String)` - If signing fails | ||
| 355 | pub fn create_announcement_event( | ||
| 356 | keys: &Keys, | ||
| 357 | identifier: &str, | ||
| 358 | clone_urls: &[&str], | ||
| 359 | relay_urls: &[&str], | ||
| 360 | ) -> Result<Event, String> { | ||
| 361 | let mut tags = vec![Tag::identifier(identifier)]; | ||
| 362 | |||
| 363 | if !clone_urls.is_empty() { | ||
| 364 | let urls: Vec<String> = clone_urls.iter().map(|s| s.to_string()).collect(); | ||
| 365 | tags.push(Tag::custom(TagKind::custom("clone"), urls)); | ||
| 366 | } | ||
| 367 | |||
| 368 | if !relay_urls.is_empty() { | ||
| 369 | let urls: Vec<String> = relay_urls.iter().map(|s| s.to_string()).collect(); | ||
| 370 | tags.push(Tag::custom(TagKind::custom("relays"), urls)); | ||
| 371 | } | ||
| 372 | |||
| 373 | EventBuilder::new(Kind::GitRepoAnnouncement, "") | ||
| 374 | .tags(tags) | ||
| 375 | .sign_with_keys(keys) | ||
| 376 | .map_err(|e| format!("Failed to sign announcement event: {}", e)) | ||
| 377 | } | ||
| 378 | |||
| 341 | /// Wait for an event to be served by a relay (not in purgatory). | 379 | /// Wait for an event to be served by a relay (not in purgatory). |
| 342 | /// | 380 | /// |
| 343 | /// Polls the relay until the event is queryable, indicating it has | 381 | /// Polls the relay until the event is queryable, indicating it has |
diff --git a/tests/purgatory_persistence.rs b/tests/purgatory_persistence.rs index 5abbf15..05cb44b 100644 --- a/tests/purgatory_persistence.rs +++ b/tests/purgatory_persistence.rs | |||
| @@ -31,9 +31,11 @@ | |||
| 31 | 31 | ||
| 32 | mod common; | 32 | mod common; |
| 33 | 33 | ||
| 34 | use common::purgatory_helpers::create_announcement_event; | ||
| 34 | use ngit_grasp::purgatory::Purgatory; | 35 | use ngit_grasp::purgatory::Purgatory; |
| 35 | use ngit_grasp::sync::rejected_index::{EventType, RejectedEventsIndex, RejectionReason}; | 36 | use ngit_grasp::sync::rejected_index::{EventType, RejectedEventsIndex, RejectionReason}; |
| 36 | use nostr_sdk::prelude::*; | 37 | use nostr_sdk::prelude::*; |
| 38 | use std::collections::HashSet; | ||
| 37 | use std::time::Duration; | 39 | use std::time::Duration; |
| 38 | 40 | ||
| 39 | /// Helper to create a test event | 41 | /// Helper to create a test event |
| @@ -116,12 +118,31 @@ async fn test_full_purgatory_save_restore_cycle() { | |||
| 116 | // Add a PR placeholder (git-data-first scenario) | 118 | // Add a PR placeholder (git-data-first scenario) |
| 117 | purgatory.add_pr_placeholder("placeholder-id".to_string(), "commit-xyz".to_string()); | 119 | purgatory.add_pr_placeholder("placeholder-id".to_string(), "commit-xyz".to_string()); |
| 118 | 120 | ||
| 119 | // Note: We can't directly test expired events without accessing private fields, | 121 | // Add an announcement to purgatory (requires a real directory for the repo path) |
| 120 | // so we'll focus on testing state and PR events persistence | 122 | let repo_dir = temp_dir.path().join("repo.git"); |
| 123 | std::fs::create_dir_all(&repo_dir).unwrap(); | ||
| 124 | let ann_keys = Keys::generate(); | ||
| 125 | let ann_event = create_announcement_event( | ||
| 126 | &ann_keys, | ||
| 127 | "my-repo", | ||
| 128 | &["http://example.com/my-repo.git"], | ||
| 129 | &["wss://relay.example.com"], | ||
| 130 | ) | ||
| 131 | .unwrap(); | ||
| 132 | let ann_event_id = ann_event.id; | ||
| 133 | let mut ann_relays = HashSet::new(); | ||
| 134 | ann_relays.insert("wss://relay.example.com".to_string()); | ||
| 135 | purgatory.add_announcement( | ||
| 136 | ann_event, | ||
| 137 | "my-repo".to_string(), | ||
| 138 | ann_keys.public_key(), | ||
| 139 | repo_dir.clone(), | ||
| 140 | ann_relays, | ||
| 141 | ); | ||
| 121 | 142 | ||
| 122 | // Verify initial counts | 143 | // Verify initial counts |
| 123 | let (announcement_count, state_count, pr_count) = purgatory.count(); | 144 | let (announcement_count, state_count, pr_count) = purgatory.count(); |
| 124 | assert_eq!(announcement_count, 0, "Should have 0 announcements"); | 145 | assert_eq!(announcement_count, 1, "Should have 1 announcement"); |
| 125 | assert_eq!(state_count, 2, "Should have 2 state events"); | 146 | assert_eq!(state_count, 2, "Should have 2 state events"); |
| 126 | assert_eq!( | 147 | assert_eq!( |
| 127 | pr_count, 3, | 148 | pr_count, 3, |
| @@ -144,13 +165,22 @@ async fn test_full_purgatory_save_restore_cycle() { | |||
| 144 | 165 | ||
| 145 | // Verify all data was restored | 166 | // Verify all data was restored |
| 146 | let (announcement_count2, state_count2, pr_count2) = purgatory2.count(); | 167 | let (announcement_count2, state_count2, pr_count2) = purgatory2.count(); |
| 147 | assert_eq!(announcement_count2, 0, "Should have 0 announcements after restore"); | 168 | assert_eq!(announcement_count2, 1, "Should have 1 announcement after restore"); |
| 148 | assert_eq!(state_count2, 2, "Should have 2 state events after restore"); | 169 | assert_eq!(state_count2, 2, "Should have 2 state events after restore"); |
| 149 | assert_eq!( | 170 | assert_eq!( |
| 150 | pr_count2, 3, | 171 | pr_count2, 3, |
| 151 | "Should have 3 PR events after restore (2 events + 1 placeholder)" | 172 | "Should have 3 PR events after restore (2 events + 1 placeholder)" |
| 152 | ); | 173 | ); |
| 153 | 174 | ||
| 175 | // Verify announcement was restored correctly | ||
| 176 | let restored_ann = purgatory2 | ||
| 177 | .find_announcement(&ann_keys.public_key(), "my-repo") | ||
| 178 | .expect("Announcement should be restored"); | ||
| 179 | assert_eq!(restored_ann.event.id, ann_event_id); | ||
| 180 | assert_eq!(restored_ann.identifier, "my-repo"); | ||
| 181 | assert_eq!(restored_ann.repo_path, repo_dir); | ||
| 182 | assert!(!restored_ann.soft_expired); | ||
| 183 | |||
| 154 | // Verify specific state events | 184 | // Verify specific state events |
| 155 | let repo1_states = purgatory2.find_state("repo1"); | 185 | let repo1_states = purgatory2.find_state("repo1"); |
| 156 | assert_eq!(repo1_states.len(), 1); | 186 | assert_eq!(repo1_states.len(), 1); |
| @@ -748,3 +778,100 @@ async fn test_rejected_cache_entries_expired_during_downtime() { | |||
| 748 | assert_eq!(index2.hot_cache_len(), 0); | 778 | assert_eq!(index2.hot_cache_len(), 0); |
| 749 | assert_eq!(index2.cold_index_len(), 1); | 779 | assert_eq!(index2.cold_index_len(), 1); |
| 750 | } | 780 | } |
| 781 | |||
| 782 | /// Test 18: Announcement events are saved and restored across restarts | ||
| 783 | #[tokio::test] | ||
| 784 | async fn test_announcement_save_restore_cycle() { | ||
| 785 | let temp_dir = tempfile::tempdir().unwrap(); | ||
| 786 | let git_data_path = temp_dir.path().join("git"); | ||
| 787 | let state_path = temp_dir.path().join("purgatory.json"); | ||
| 788 | |||
| 789 | // Create a real bare repo directory (restore skips entries whose path is missing) | ||
| 790 | let repo_dir = temp_dir.path().join("owner.git"); | ||
| 791 | std::fs::create_dir_all(&repo_dir).unwrap(); | ||
| 792 | |||
| 793 | let purgatory = Purgatory::new(&git_data_path); | ||
| 794 | let keys = Keys::generate(); | ||
| 795 | |||
| 796 | let ann_event = create_announcement_event( | ||
| 797 | &keys, | ||
| 798 | "my-repo", | ||
| 799 | &["http://example.com/my-repo.git"], | ||
| 800 | &["wss://relay.example.com"], | ||
| 801 | ) | ||
| 802 | .unwrap(); | ||
| 803 | let ann_event_id = ann_event.id; | ||
| 804 | |||
| 805 | let mut relays = HashSet::new(); | ||
| 806 | relays.insert("wss://relay.example.com".to_string()); | ||
| 807 | |||
| 808 | purgatory.add_announcement( | ||
| 809 | ann_event, | ||
| 810 | "my-repo".to_string(), | ||
| 811 | keys.public_key(), | ||
| 812 | repo_dir.clone(), | ||
| 813 | relays.clone(), | ||
| 814 | ); | ||
| 815 | |||
| 816 | let (ann_count, _, _) = purgatory.count(); | ||
| 817 | assert_eq!(ann_count, 1); | ||
| 818 | |||
| 819 | // Save to disk | ||
| 820 | purgatory.save_to_disk(&state_path).unwrap(); | ||
| 821 | assert!(state_path.exists()); | ||
| 822 | |||
| 823 | // Restore into a fresh purgatory | ||
| 824 | let purgatory2 = Purgatory::new(&git_data_path); | ||
| 825 | purgatory2.restore_from_disk(&state_path).unwrap(); | ||
| 826 | |||
| 827 | assert!(!state_path.exists(), "State file should be deleted after restore"); | ||
| 828 | |||
| 829 | let (ann_count2, _, _) = purgatory2.count(); | ||
| 830 | assert_eq!(ann_count2, 1, "Announcement should be restored"); | ||
| 831 | |||
| 832 | let restored = purgatory2 | ||
| 833 | .find_announcement(&keys.public_key(), "my-repo") | ||
| 834 | .expect("Announcement should be findable after restore"); | ||
| 835 | |||
| 836 | assert_eq!(restored.event.id, ann_event_id); | ||
| 837 | assert_eq!(restored.identifier, "my-repo"); | ||
| 838 | assert_eq!(restored.owner, keys.public_key()); | ||
| 839 | assert_eq!(restored.repo_path, repo_dir); | ||
| 840 | assert_eq!(restored.relays, relays); | ||
| 841 | assert!(!restored.soft_expired); | ||
| 842 | } | ||
| 843 | |||
| 844 | /// Test 19: Announcement with missing repo path is skipped on restore | ||
| 845 | #[tokio::test] | ||
| 846 | async fn test_announcement_missing_repo_skipped_on_restore() { | ||
| 847 | let temp_dir = tempfile::tempdir().unwrap(); | ||
| 848 | let git_data_path = temp_dir.path().join("git"); | ||
| 849 | let state_path = temp_dir.path().join("purgatory.json"); | ||
| 850 | |||
| 851 | // Point to a path that does NOT exist on disk | ||
| 852 | let missing_repo = temp_dir.path().join("nonexistent.git"); | ||
| 853 | |||
| 854 | let purgatory = Purgatory::new(&git_data_path); | ||
| 855 | let keys = Keys::generate(); | ||
| 856 | |||
| 857 | let ann_event = create_announcement_event(&keys, "my-repo", &[], &[]).unwrap(); | ||
| 858 | |||
| 859 | purgatory.add_announcement( | ||
| 860 | ann_event, | ||
| 861 | "my-repo".to_string(), | ||
| 862 | keys.public_key(), | ||
| 863 | missing_repo, | ||
| 864 | HashSet::new(), | ||
| 865 | ); | ||
| 866 | |||
| 867 | purgatory.save_to_disk(&state_path).unwrap(); | ||
| 868 | |||
| 869 | let purgatory2 = Purgatory::new(&git_data_path); | ||
| 870 | purgatory2.restore_from_disk(&state_path).unwrap(); | ||
| 871 | |||
| 872 | let (ann_count, _, _) = purgatory2.count(); | ||
| 873 | assert_eq!( | ||
| 874 | ann_count, 0, | ||
| 875 | "Announcement with missing repo path must be skipped" | ||
| 876 | ); | ||
| 877 | } | ||