upleb.uk

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

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2025-12-08 20:39:58 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2025-12-08 20:39:58 +0000
commit91dc5e8d718475a73815892452a58e1dbf56c8d9 (patch)
tree4db4a5afe7f9457a348f8cb9e3f4d8e3a6c7e7b0
parent103ede51485601892af1df6dab9f96f232b10f49 (diff)
delete old bad AI genreated tests
-rw-r--r--tests/proactive_sync_catchup.rs330
-rw-r--r--tests/proactive_sync_dynamic.rs661
-rw-r--r--tests/proactive_sync_multi.rs194
-rw-r--r--tests/proactive_sync_resilience.rs476
4 files changed, 0 insertions, 1661 deletions
diff --git a/tests/proactive_sync_catchup.rs b/tests/proactive_sync_catchup.rs
deleted file mode 100644
index d8a2ef9..0000000
--- a/tests/proactive_sync_catchup.rs
+++ /dev/null
@@ -1,330 +0,0 @@
1//! GRASP-02 Phase 5: Negentropy Catchup Integration Tests
2//!
3//! Tests verify negentropy catchup functionality:
4//! - Startup catchup after warm-up delay (30s default)
5//! - Reconnect catchup recovers recent gaps (last 3 days)
6//! - Daily catchup runs once per 24h with stagger
7//! - Catchup uses same filters as live sync
8//! - Gap events logged at WARN level
9//!
10//! # Running Tests
11//!
12//! ```bash
13//! cargo test --test proactive_sync_catchup
14//! cargo test --test proactive_sync_catchup -- --nocapture
15//! ```
16
17use ngit_grasp::sync::SubscriptionManager;
18
19// ============================================================================
20// Catchup State Machine Tests
21// ============================================================================
22
23/// Test startup catchup should only run once
24#[test]
25fn test_startup_catchup_runs_once() {
26 // After startup catchup completes, should_run_startup_catchup should return false
27 // This is handled by the startup_catchup_completed flag in NegentropyService
28
29 // Simulating the state machine:
30 let mut startup_completed = false;
31
32 // Before running, should return true (if delay elapsed)
33 let should_run_before = !startup_completed;
34 assert!(should_run_before);
35
36 // After running, mark as completed
37 startup_completed = true;
38
39 // Now should return false
40 let should_run_after = !startup_completed;
41 assert!(!should_run_after);
42}
43
44/// Test daily catchup interval checking
45#[test]
46fn test_daily_catchup_interval_check() {
47 use std::time::{Duration, Instant};
48
49 const DAILY_INTERVAL_SECS: u64 = 86400;
50
51 // Simulate last catchup time
52 let last_catchup = Instant::now();
53
54 // Immediately after, should not run
55 let should_run_immediately = last_catchup.elapsed() >= Duration::from_secs(DAILY_INTERVAL_SECS);
56 assert!(!should_run_immediately);
57}
58
59/// Test that new relay (no previous catchup) should run daily catchup
60#[test]
61fn test_new_relay_should_run_daily_catchup() {
62 use std::collections::HashMap;
63 use std::time::Instant;
64
65 let last_daily_catchup: HashMap<String, Instant> = HashMap::new();
66 let relay_url = "wss://test-relay.example.com";
67
68 // No previous catchup recorded, should return true
69 let should_run = !last_daily_catchup.contains_key(relay_url);
70 assert!(should_run);
71}
72
73/// Test reconnect catchup only after successful reconnection
74#[test]
75fn test_reconnect_catchup_after_reconnection() {
76 // Reconnect catchup should only trigger when:
77 // 1. Connection was previously successful (had_previous_connection = true)
78 // 2. Connection was lost and restored
79
80 let mut had_previous_connection = false;
81
82 // First connection - should NOT trigger reconnect catchup
83 let is_reconnection_first = had_previous_connection;
84 assert!(!is_reconnection_first);
85 had_previous_connection = true;
86
87 // Second connection (after disconnection) - SHOULD trigger
88 let is_reconnection_second = had_previous_connection;
89 assert!(is_reconnection_second);
90}
91
92// ============================================================================
93// Gap Event Flow Tests
94// ============================================================================
95
96/// Test that gap events go through policy validation
97#[test]
98fn test_gap_events_validated_through_policy() {
99 // The NegentropyService uses write_policy.admit_event() for validation
100 // This test verifies the flow exists:
101 // 1. Fetch events from relay
102 // 2. Check if event exists locally
103 // 3. Validate through Nip34WritePolicy
104 // 4. Store if accepted
105
106 // This is verified by the implementation in negentropy.rs:run_catchup()
107 // where PolicyResult::Accept leads to storage and PolicyResult::Reject is logged
108
109 assert!(true); // Flow verification - actual validation tested in other tests
110}
111
112/// Test that gap events are distinguished from live events
113#[test]
114fn test_gap_events_logged_at_warn_level() {
115 // The spec requires gap events to be logged at WARN level
116 // to distinguish them from live events (which are logged at INFO)
117
118 // This is implemented in negentropy.rs with:
119 // tracing::warn!("Gap event filled via {} catchup: {} (kind {})", ...)
120
121 // We verify the logging pattern exists by testing the catchup types
122 let catchup_types = ["startup", "reconnect", "daily"];
123 assert_eq!(catchup_types.len(), 3);
124
125 for catchup_type in catchup_types {
126 assert!(!catchup_type.is_empty());
127 }
128}
129
130// ============================================================================
131// Stagger Logic Tests
132// ============================================================================
133
134/// Test stagger delay calculation for multiple relays
135#[test]
136fn test_stagger_delay_for_multiple_relays() {
137 const STAGGER_SECS: u64 = 300; // 5 minutes
138
139 let _relay_urls = vec![
140 "wss://relay1.example.com",
141 "wss://relay2.example.com",
142 "wss://relay3.example.com",
143 ];
144
145 // First relay (index 0) should have no stagger
146 let stagger_0 = 0 * STAGGER_SECS;
147 assert_eq!(stagger_0, 0);
148
149 // Second relay (index 1) should have 5 minute stagger
150 let stagger_1 = 1 * STAGGER_SECS;
151 assert_eq!(stagger_1, 300);
152
153 // Third relay (index 2) should have 10 minute stagger
154 let stagger_2 = 2 * STAGGER_SECS;
155 assert_eq!(stagger_2, 600);
156}
157
158/// Test that startup catchup waits for warm-up
159#[test]
160fn test_startup_catchup_waits_for_warmup() {
161 use std::time::{Duration, Instant};
162
163 const STARTUP_DELAY_SECS: u64 = 30;
164
165 let startup_time = Instant::now();
166
167 // Immediately after startup, should not run (delay not elapsed)
168 let elapsed = startup_time.elapsed();
169 let should_run = elapsed >= Duration::from_secs(STARTUP_DELAY_SECS);
170
171 // This should be false since we just created startup_time
172 assert!(!should_run);
173}
174
175// ============================================================================
176// Lookback Period Tests
177// ============================================================================
178
179/// Test reconnect lookback calculation
180#[test]
181fn test_reconnect_lookback_calculation() {
182 // 3 days = 3 * 24 * 60 * 60 = 259,200 seconds
183 let lookback_days: u64 = 3;
184 let lookback_secs = lookback_days * 24 * 60 * 60;
185
186 assert_eq!(lookback_secs, 259200);
187}
188
189/// Test that daily catchup uses no lookback (full reconciliation)
190#[test]
191fn test_daily_catchup_full_reconciliation() {
192 // Daily catchup should reconcile all events, not just recent ones
193 // This is implemented by passing None to the since parameter
194 let since: Option<u64> = None;
195 assert!(since.is_none());
196}
197
198// ============================================================================
199// Three Catchup Scenario Tests
200// ============================================================================
201
202/// Test startup catchup scenario
203#[test]
204fn test_startup_catchup_scenario() {
205 // Startup catchup:
206 // 1. Wait 30s for warm-up
207 // 2. Run full reconciliation (no time limit)
208 // 3. Mark as completed (runs only once)
209 // 4. Stagger between relays (5 minutes)
210
211 const STARTUP_DELAY: u64 = 30;
212 const STAGGER: u64 = 300;
213
214 assert_eq!(STARTUP_DELAY, 30);
215 assert_eq!(STAGGER, 300);
216}
217
218/// Test reconnect catchup scenario
219#[test]
220fn test_reconnect_catchup_scenario() {
221 // Reconnect catchup:
222 // 1. Trigger after connection restore (not first connection)
223 // 2. Wait 10s reconnect delay
224 // 3. Only fetch last 3 days of events
225 // 4. Runs in background (doesn't block connection)
226
227 const RECONNECT_DELAY: u64 = 10;
228 const LOOKBACK_DAYS: u64 = 3;
229
230 assert_eq!(RECONNECT_DELAY, 10);
231 assert_eq!(LOOKBACK_DAYS, 3);
232}
233
234/// Test daily catchup scenario
235#[test]
236fn test_daily_catchup_scenario() {
237 // Daily catchup:
238 // 1. Check hourly if any relay needs catchup
239 // 2. Run if 24h elapsed since last catchup for that relay
240 // 3. Full reconciliation (no time limit)
241 // 4. Stagger between relays (5 minutes)
242
243 const CHECK_INTERVAL: u64 = 3600; // 1 hour
244 const DAILY_INTERVAL: u64 = 86400; // 24 hours
245 const STAGGER: u64 = 300; // 5 minutes
246
247 assert_eq!(CHECK_INTERVAL, 3600);
248 assert_eq!(DAILY_INTERVAL, 86400);
249 assert_eq!(STAGGER, 300);
250}
251
252// ============================================================================
253// Event Existence Check Tests
254// ============================================================================
255
256/// Test that existing events are skipped during catchup
257#[test]
258fn test_existing_events_skipped() {
259 // The catchup flow should:
260 // 1. Fetch events from relay
261 // 2. For each event, check if it exists locally
262 // 3. Skip if exists, validate and store if not
263
264 // This is implemented in negentropy.rs:event_exists_locally()
265 // which queries the database for the event by ID
266
267 const SKIP_EXISTING: bool = true;
268 assert!(SKIP_EXISTING);
269}
270
271/// Test duplicate prevention during catchup
272#[test]
273fn test_duplicate_prevention() {
274 use std::collections::HashSet;
275
276 let mut processed_ids: HashSet<String> = HashSet::new();
277 let event_id = "abc123def456".to_string();
278
279 // First time seeing this event - should process
280 let is_new = !processed_ids.contains(&event_id);
281 assert!(is_new);
282 processed_ids.insert(event_id.clone());
283
284 // Second time - should skip
285 let is_duplicate = processed_ids.contains(&event_id);
286 assert!(is_duplicate);
287}
288
289// ============================================================================
290// Configuration Integration Tests
291// ============================================================================
292
293/// Test config fields exist for catchup timing
294#[test]
295fn test_config_fields_for_catchup() {
296 // The Config struct should have these fields:
297 // - sync_startup_delay_secs (default: 30)
298 // - sync_reconnect_delay_secs (default: 10)
299 // - sync_reconnect_lookback_days (default: 3)
300
301 // Environment variables:
302 // - NGIT_SYNC_STARTUP_DELAY_SECS
303 // - NGIT_SYNC_RECONNECT_DELAY_SECS
304 // - NGIT_SYNC_RECONNECT_LOOKBACK_DAYS
305
306 let expected_defaults = vec![
307 ("startup_delay_secs", 30u64),
308 ("reconnect_delay_secs", 10u64),
309 ("reconnect_lookback_days", 3u64),
310 ];
311
312 assert_eq!(expected_defaults.len(), 3);
313 assert_eq!(expected_defaults[0].1, 30);
314 assert_eq!(expected_defaults[1].1, 10);
315 assert_eq!(expected_defaults[2].1, 3);
316}
317
318/// Test that catchup respects configured delays
319#[test]
320fn test_catchup_respects_config() {
321 // Custom delays should be used instead of defaults
322 let custom_startup_delay: u64 = 60;
323 let custom_reconnect_delay: u64 = 20;
324 let custom_lookback_days: u64 = 7;
325
326 // All should be configurable to non-default values
327 assert_ne!(custom_startup_delay, 30);
328 assert_ne!(custom_reconnect_delay, 10);
329 assert_ne!(custom_lookback_days, 3);
330}
diff --git a/tests/proactive_sync_dynamic.rs b/tests/proactive_sync_dynamic.rs
deleted file mode 100644
index 2d3232f..0000000
--- a/tests/proactive_sync_dynamic.rs
+++ /dev/null
@@ -1,661 +0,0 @@
1//! GRASP-02 Phase 4: Dynamic Subscription Integration Tests
2//!
3//! Tests verify dynamic subscription management:
4//! - New announcement triggers Layer 2 subscription
5//! - New PR/Issue triggers Layer 3 subscription
6//! - Subscription count tracking per connection
7//! - Consolidation at filter count > 150
8//! - No duplicate subscriptions
9//!
10//! # Running Tests
11//!
12//! ```bash
13//! cargo test --test proactive_sync_dynamic
14//! cargo test --test proactive_sync_dynamic -- --nocapture
15//! ```
16
17use std::collections::HashSet;
18
19use ngit_grasp::sync::SubscriptionManager;
20use nostr_sdk::prelude::*;
21
22/// Kind 30617 - Repository Announcement (NIP-34)
23const KIND_REPOSITORY_ANNOUNCEMENT: u16 = 30617;
24
25/// Kind 30618 - Maintainer List (NIP-34)
26const KIND_MAINTAINER_LIST: u16 = 30618;
27
28/// Maximum filters before consolidation (from spec)
29const CONSOLIDATION_THRESHOLD: usize = 150;
30
31/// Helper to create a test announcement event
32fn create_test_announcement(keys: &Keys, identifier: &str) -> Event {
33 let tags = vec![
34 Tag::identifier(identifier),
35 Tag::custom(
36 TagKind::custom("clone"),
37 vec![format!("http://test.example.com/{}", identifier)],
38 ),
39 Tag::custom(
40 TagKind::custom("relays"),
41 vec!["ws://test.example.com".to_string()],
42 ),
43 ];
44
45 EventBuilder::new(Kind::Custom(KIND_REPOSITORY_ANNOUNCEMENT), "Test repo")
46 .tags(tags)
47 .sign_with_keys(keys)
48 .expect("Failed to sign event")
49}
50
51/// Helper to create a test maintainer list event
52fn create_test_maintainer_list(keys: &Keys, identifier: &str) -> Event {
53 let tags = vec![
54 Tag::identifier(identifier),
55 Tag::custom(
56 TagKind::custom("relays"),
57 vec!["ws://test.example.com".to_string()],
58 ),
59 ];
60
61 EventBuilder::new(Kind::Custom(KIND_MAINTAINER_LIST), "Maintainer list")
62 .tags(tags)
63 .sign_with_keys(keys)
64 .expect("Failed to sign event")
65}
66
67/// Helper to create a test PR event (kind 1617)
68fn create_test_pr_event(keys: &Keys, repo_coord: &str) -> Event {
69 let tags = vec![Tag::custom(
70 TagKind::custom("a"),
71 vec![repo_coord.to_string()],
72 )];
73
74 EventBuilder::new(Kind::Custom(1617), "Test patch proposal")
75 .tags(tags)
76 .sign_with_keys(keys)
77 .expect("Failed to sign event")
78}
79
80/// Helper to create a test PR event (kind 1618)
81fn create_test_pr_1618_event(keys: &Keys, repo_coord: &str) -> Event {
82 let tags = vec![Tag::custom(
83 TagKind::custom("a"),
84 vec![repo_coord.to_string()],
85 )];
86
87 EventBuilder::new(Kind::Custom(1618), "Test PR")
88 .tags(tags)
89 .sign_with_keys(keys)
90 .expect("Failed to sign event")
91}
92
93/// Helper to create a test Issue event (kind 1621)
94fn create_test_issue_event(keys: &Keys, repo_coord: &str) -> Event {
95 let tags = vec![Tag::custom(
96 TagKind::custom("a"),
97 vec![repo_coord.to_string()],
98 )];
99
100 EventBuilder::new(Kind::Custom(1621), "Test issue")
101 .tags(tags)
102 .sign_with_keys(keys)
103 .expect("Failed to sign event")
104}
105
106/// Helper to create a test Reply event (kind 1622)
107fn create_test_reply_event(keys: &Keys, event_id: &str) -> Event {
108 let tags = vec![Tag::custom(
109 TagKind::custom("e"),
110 vec![event_id.to_string()],
111 )];
112
113 EventBuilder::new(Kind::Custom(1622), "Test reply")
114 .tags(tags)
115 .sign_with_keys(keys)
116 .expect("Failed to sign event")
117}
118
119// ============================================================================
120// Filter Count Tests
121// ============================================================================
122
123/// Test initial filter count is 1 (Layer 1 only)
124#[test]
125fn test_initial_filter_count() {
126 // Create a minimal SubscriptionManager-like state for testing
127 // We test the logic without needing a full FilterService
128
129 // Initial state: 0 announcements, 0 events, not consolidated
130 // Filter count should be: 1 (Layer 1) + 0 + 0 = 1
131 let announcement_count = 0;
132 let event_count = 0;
133 let is_consolidated = false;
134
135 let filter_count = if is_consolidated {
136 1
137 } else {
138 1 + announcement_count + event_count
139 };
140
141 assert_eq!(filter_count, 1);
142}
143
144/// Test filter count increases with announcements
145#[test]
146fn test_filter_count_with_announcements() {
147 let announcement_count = 5;
148 let event_count = 0;
149 let is_consolidated = false;
150
151 let filter_count = if is_consolidated {
152 1
153 } else {
154 1 + announcement_count + event_count
155 };
156
157 // 1 (Layer 1) + 5 (announcements) = 6
158 assert_eq!(filter_count, 6);
159}
160
161/// Test filter count increases with events
162#[test]
163fn test_filter_count_with_events() {
164 let announcement_count = 0;
165 let event_count = 10;
166 let is_consolidated = false;
167
168 let filter_count = if is_consolidated {
169 1
170 } else {
171 1 + announcement_count + event_count
172 };
173
174 // 1 (Layer 1) + 10 (events) = 11
175 assert_eq!(filter_count, 11);
176}
177
178/// Test filter count with both announcements and events
179#[test]
180fn test_filter_count_mixed() {
181 let announcement_count = 50;
182 let event_count = 30;
183 let is_consolidated = false;
184
185 let filter_count = if is_consolidated {
186 1
187 } else {
188 1 + announcement_count + event_count
189 };
190
191 // 1 + 50 + 30 = 81
192 assert_eq!(filter_count, 81);
193}
194
195/// Test filter count is 1 when consolidated
196#[test]
197fn test_filter_count_consolidated() {
198 let announcement_count = 100; // These would be cleared on consolidation
199 let event_count = 100;
200 let is_consolidated = true;
201
202 let filter_count = if is_consolidated {
203 1
204 } else {
205 1 + announcement_count + event_count
206 };
207
208 assert_eq!(filter_count, 1);
209}
210
211// ============================================================================
212// Consolidation Threshold Tests
213// ============================================================================
214
215/// Test consolidation is not triggered below threshold
216#[test]
217fn test_should_consolidate_below_threshold() {
218 let filter_count = 100;
219 let is_consolidated = false;
220
221 let should_consolidate = !is_consolidated && filter_count > CONSOLIDATION_THRESHOLD;
222
223 assert!(!should_consolidate);
224}
225
226/// Test consolidation is triggered at threshold
227#[test]
228fn test_should_consolidate_at_threshold() {
229 let filter_count = 151; // > 150
230 let is_consolidated = false;
231
232 let should_consolidate = !is_consolidated && filter_count > CONSOLIDATION_THRESHOLD;
233
234 assert!(should_consolidate);
235}
236
237/// Test consolidation is triggered well above threshold
238#[test]
239fn test_should_consolidate_above_threshold() {
240 let filter_count = 200;
241 let is_consolidated = false;
242
243 let should_consolidate = !is_consolidated && filter_count > CONSOLIDATION_THRESHOLD;
244
245 assert!(should_consolidate);
246}
247
248/// Test consolidation is not triggered if already consolidated
249#[test]
250fn test_should_consolidate_already_consolidated() {
251 let filter_count = 200; // Would trigger, but already consolidated
252 let is_consolidated = true;
253
254 let should_consolidate = !is_consolidated && filter_count > CONSOLIDATION_THRESHOLD;
255
256 assert!(!should_consolidate);
257}
258
259/// Test exact threshold boundary (150 should NOT trigger, 151 should)
260#[test]
261fn test_consolidation_threshold_boundary() {
262 let is_consolidated = false;
263
264 // 150 should NOT trigger (> 150, not >= 150)
265 let should_consolidate_at_150 = !is_consolidated && 150 > CONSOLIDATION_THRESHOLD;
266 assert!(!should_consolidate_at_150);
267
268 // 151 should trigger
269 let should_consolidate_at_151 = !is_consolidated && 151 > CONSOLIDATION_THRESHOLD;
270 assert!(should_consolidate_at_151);
271}
272
273// ============================================================================
274// Duplicate Prevention Tests
275// ============================================================================
276
277/// Test duplicate announcement detection
278#[test]
279fn test_duplicate_announcement_prevention() {
280 let mut subscribed_announcements: HashSet<String> = HashSet::new();
281
282 let event_id = "abc123".to_string();
283
284 // First add should succeed
285 let is_new = !subscribed_announcements.contains(&event_id);
286 assert!(is_new);
287 subscribed_announcements.insert(event_id.clone());
288
289 // Second add should fail (duplicate)
290 let is_new_again = !subscribed_announcements.contains(&event_id);
291 assert!(!is_new_again);
292}
293
294/// Test duplicate event detection
295#[test]
296fn test_duplicate_event_prevention() {
297 let mut subscribed_events: HashSet<String> = HashSet::new();
298
299 let event_id = "def456".to_string();
300
301 // First add should succeed
302 let is_new = !subscribed_events.contains(&event_id);
303 assert!(is_new);
304 subscribed_events.insert(event_id.clone());
305
306 // Second add should fail (duplicate)
307 let is_new_again = !subscribed_events.contains(&event_id);
308 assert!(!is_new_again);
309}
310
311/// Test multiple unique items are tracked correctly
312#[test]
313fn test_multiple_unique_items_tracked() {
314 let mut subscribed_announcements: HashSet<String> = HashSet::new();
315
316 // Add multiple unique announcements
317 for i in 0..10 {
318 let id = format!("announcement_{}", i);
319 assert!(!subscribed_announcements.contains(&id));
320 subscribed_announcements.insert(id);
321 }
322
323 assert_eq!(subscribed_announcements.len(), 10);
324}
325
326// ============================================================================
327// Event Creation and Validation Tests
328// ============================================================================
329
330/// Test announcement event has required d tag
331#[test]
332fn test_announcement_has_d_tag() {
333 let keys = Keys::generate();
334 let event = create_test_announcement(&keys, "my-repo");
335
336 let has_d_tag = event.tags.iter().any(|tag| {
337 let tag_vec = tag.clone().to_vec();
338 tag_vec.len() >= 2 && tag_vec[0] == "d"
339 });
340
341 assert!(has_d_tag);
342}
343
344/// Test announcement event has correct kind
345#[test]
346fn test_announcement_correct_kind() {
347 let keys = Keys::generate();
348 let event = create_test_announcement(&keys, "my-repo");
349
350 assert_eq!(event.kind.as_u16(), KIND_REPOSITORY_ANNOUNCEMENT);
351}
352
353/// Test maintainer list event has correct kind
354#[test]
355fn test_maintainer_list_correct_kind() {
356 let keys = Keys::generate();
357 let event = create_test_maintainer_list(&keys, "maintainers");
358
359 assert_eq!(event.kind.as_u16(), KIND_MAINTAINER_LIST);
360}
361
362/// Test PR event has a tag
363#[test]
364fn test_pr_event_has_a_tag() {
365 let keys = Keys::generate();
366 let coord = "30617:pubkey123:my-repo";
367 let event = create_test_pr_event(&keys, coord);
368
369 let has_a_tag = event.tags.iter().any(|tag| {
370 let tag_vec = tag.clone().to_vec();
371 tag_vec.len() >= 2 && tag_vec[0] == "a"
372 });
373
374 assert!(has_a_tag);
375}
376
377/// Test issue event has a tag
378#[test]
379fn test_issue_event_has_a_tag() {
380 let keys = Keys::generate();
381 let coord = "30617:pubkey123:my-repo";
382 let event = create_test_issue_event(&keys, coord);
383
384 let has_a_tag = event.tags.iter().any(|tag| {
385 let tag_vec = tag.clone().to_vec();
386 tag_vec.len() >= 2 && tag_vec[0] == "a"
387 });
388
389 assert!(has_a_tag);
390}
391
392/// Test reply event has e tag
393#[test]
394fn test_reply_event_has_e_tag() {
395 let keys = Keys::generate();
396 let event_id = "abc123def456";
397 let event = create_test_reply_event(&keys, event_id);
398
399 let has_e_tag = event.tags.iter().any(|tag| {
400 let tag_vec = tag.clone().to_vec();
401 tag_vec.len() >= 2 && tag_vec[0] == "e"
402 });
403
404 assert!(has_e_tag);
405}
406
407// ============================================================================
408// Subscription Lifecycle Tests
409// ============================================================================
410
411/// Test subscription lifecycle: initial -> add announcements -> add events -> consolidate
412#[test]
413fn test_subscription_lifecycle() {
414 let mut subscribed_announcements: HashSet<String> = HashSet::new();
415 let mut subscribed_events: HashSet<String> = HashSet::new();
416 let mut is_consolidated = false;
417
418 // Initial state
419 let initial_count = 1 + subscribed_announcements.len() + subscribed_events.len();
420 assert_eq!(initial_count, 1);
421
422 // Add some announcements
423 for i in 0..50 {
424 subscribed_announcements.insert(format!("ann_{}", i));
425 }
426
427 let after_announcements = 1 + subscribed_announcements.len() + subscribed_events.len();
428 assert_eq!(after_announcements, 51);
429
430 // Add some events
431 for i in 0..50 {
432 subscribed_events.insert(format!("evt_{}", i));
433 }
434
435 let after_events = 1 + subscribed_announcements.len() + subscribed_events.len();
436 assert_eq!(after_events, 101);
437
438 // Add more to exceed threshold
439 for i in 50..100 {
440 subscribed_announcements.insert(format!("ann_{}", i));
441 }
442
443 let before_consolidation = 1 + subscribed_announcements.len() + subscribed_events.len();
444 assert_eq!(before_consolidation, 151);
445
446 // Should trigger consolidation
447 let should_consolidate = !is_consolidated && before_consolidation > CONSOLIDATION_THRESHOLD;
448 assert!(should_consolidate);
449
450 // Consolidate
451 subscribed_announcements.clear();
452 subscribed_events.clear();
453 is_consolidated = true;
454
455 // After consolidation
456 let after_consolidation = if is_consolidated {
457 1
458 } else {
459 1 + subscribed_announcements.len() + subscribed_events.len()
460 };
461 assert_eq!(after_consolidation, 1);
462
463 // Should not trigger consolidation again
464 let should_consolidate_again =
465 !is_consolidated && after_consolidation > CONSOLIDATION_THRESHOLD;
466 assert!(!should_consolidate_again);
467}
468
469/// Test that consolidated state blocks new additions
470#[test]
471fn test_consolidated_blocks_additions() {
472 let is_consolidated = true;
473
474 // When consolidated, add_announcement should return None (simulated)
475 // The logic is: if is_consolidated, return None
476 let should_add = !is_consolidated;
477
478 assert!(!should_add);
479}
480
481/// Test that non-consolidated state allows additions
482#[test]
483fn test_non_consolidated_allows_additions() {
484 let is_consolidated = false;
485 let mut subscribed_announcements: HashSet<String> = HashSet::new();
486 let event_id = "new_announcement";
487
488 // When not consolidated and event not in set, should add
489 let should_add = !is_consolidated && !subscribed_announcements.contains(event_id);
490
491 assert!(should_add);
492
493 subscribed_announcements.insert(event_id.to_string());
494 assert!(subscribed_announcements.contains(event_id));
495}
496
497// ============================================================================
498// Filter Building Tests (coordinate format)
499// ============================================================================
500
501/// Test announcement coordinate format
502#[test]
503fn test_announcement_coordinate_format() {
504 let keys = Keys::generate();
505 let identifier = "my-repo";
506 let event = create_test_announcement(&keys, identifier);
507
508 // Extract d tag
509 let d_tag = event.tags.iter().find_map(|tag| {
510 let tag_vec = tag.clone().to_vec();
511 if tag_vec.len() >= 2 && tag_vec[0] == "d" {
512 Some(tag_vec[1].clone())
513 } else {
514 None
515 }
516 });
517
518 assert!(d_tag.is_some());
519 assert_eq!(d_tag.unwrap(), identifier);
520
521 // Build coordinate: kind:pubkey:identifier
522 let coord = format!(
523 "{}:{}:{}",
524 KIND_REPOSITORY_ANNOUNCEMENT,
525 event.pubkey.to_hex(),
526 identifier
527 );
528
529 // Verify format
530 let parts: Vec<&str> = coord.split(':').collect();
531 assert_eq!(parts.len(), 3);
532 assert_eq!(parts[0], "30617");
533 assert_eq!(parts[2], identifier);
534}
535
536/// Test multiple announcement coordinates are unique
537#[test]
538fn test_multiple_announcement_coordinates_unique() {
539 let keys = Keys::generate();
540
541 let identifiers = vec!["repo1", "repo2", "repo3"];
542 let mut coords: HashSet<String> = HashSet::new();
543
544 for id in identifiers {
545 let event = create_test_announcement(&keys, id);
546 let coord = format!(
547 "{}:{}:{}",
548 KIND_REPOSITORY_ANNOUNCEMENT,
549 event.pubkey.to_hex(),
550 id
551 );
552 coords.insert(coord);
553 }
554
555 assert_eq!(coords.len(), 3);
556}
557
558// ============================================================================
559// Integration-style Tests
560// ============================================================================
561
562/// Test simulated workflow: announcement received, then PR received
563#[test]
564fn test_workflow_announcement_then_pr() {
565 let keys = Keys::generate();
566 let mut subscribed_announcements: HashSet<String> = HashSet::new();
567 let mut subscribed_events: HashSet<String> = HashSet::new();
568 let is_consolidated = false;
569
570 // Step 1: Receive announcement
571 let announcement = create_test_announcement(&keys, "my-repo");
572 let ann_id = announcement.id.to_hex();
573
574 // Should add to tracking (simulating add_announcement)
575 let should_add_ann = !is_consolidated && !subscribed_announcements.contains(&ann_id);
576 assert!(should_add_ann);
577 subscribed_announcements.insert(ann_id.clone());
578
579 // Filter count should increase
580 let filter_count = 1 + subscribed_announcements.len() + subscribed_events.len();
581 assert_eq!(filter_count, 2);
582
583 // Step 2: Receive PR for that repo
584 let coord = format!(
585 "{}:{}:my-repo",
586 KIND_REPOSITORY_ANNOUNCEMENT,
587 keys.public_key().to_hex()
588 );
589 let pr = create_test_pr_event(&keys, &coord);
590 let pr_id = pr.id.to_hex();
591
592 // Should add to tracking (simulating add_event)
593 let should_add_pr = !is_consolidated && !subscribed_events.contains(&pr_id);
594 assert!(should_add_pr);
595 subscribed_events.insert(pr_id.clone());
596
597 // Filter count should increase again
598 let filter_count = 1 + subscribed_announcements.len() + subscribed_events.len();
599 assert_eq!(filter_count, 3);
600}
601
602/// Test stress: adding many items triggers consolidation
603#[test]
604fn test_stress_many_items_triggers_consolidation() {
605 let keys = Keys::generate();
606 let mut subscribed_announcements: HashSet<String> = HashSet::new();
607 let mut subscribed_events: HashSet<String> = HashSet::new();
608 let mut is_consolidated = false;
609 let mut consolidation_triggered = false;
610
611 // Add 100 announcements
612 for i in 0..100 {
613 let event = create_test_announcement(&keys, &format!("repo-{}", i));
614 let event_id = event.id.to_hex();
615
616 if !is_consolidated && !subscribed_announcements.contains(&event_id) {
617 subscribed_announcements.insert(event_id);
618 }
619
620 // Check consolidation after each add
621 let filter_count = 1 + subscribed_announcements.len() + subscribed_events.len();
622 if !is_consolidated && filter_count > CONSOLIDATION_THRESHOLD {
623 consolidation_triggered = true;
624 subscribed_announcements.clear();
625 subscribed_events.clear();
626 is_consolidated = true;
627 break;
628 }
629 }
630
631 // If we didn't consolidate yet, add events
632 if !consolidation_triggered {
633 for i in 0..100 {
634 let coord = format!("30617:pubkey:repo-{}", i);
635 let event = create_test_pr_event(&keys, &coord);
636 let event_id = event.id.to_hex();
637
638 if !is_consolidated && !subscribed_events.contains(&event_id) {
639 subscribed_events.insert(event_id);
640 }
641
642 // Check consolidation after each add
643 let filter_count = 1 + subscribed_announcements.len() + subscribed_events.len();
644 if !is_consolidated && filter_count > CONSOLIDATION_THRESHOLD {
645 consolidation_triggered = true;
646 subscribed_announcements.clear();
647 subscribed_events.clear();
648 is_consolidated = true;
649 break;
650 }
651 }
652 }
653
654 // Consolidation should have been triggered
655 assert!(consolidation_triggered);
656 assert!(is_consolidated);
657
658 // After consolidation, counts should be reset
659 assert_eq!(subscribed_announcements.len(), 0);
660 assert_eq!(subscribed_events.len(), 0);
661}
diff --git a/tests/proactive_sync_multi.rs b/tests/proactive_sync_multi.rs
deleted file mode 100644
index de4183a..0000000
--- a/tests/proactive_sync_multi.rs
+++ /dev/null
@@ -1,194 +0,0 @@
1//! GRASP-02 Phase 2: Multi-Relay Proactive Sync Integration Tests
2//!
3//! Tests the multi-relay proactive sync functionality.
4//!
5//! Note: Integration tests for sync timing are inherently flaky due to
6//! subprocess communication latency. Unit tests for FilterService and
7//! SyncManager cover the core logic in src/sync/filter.rs and manager.rs.
8//!
9//! # Running Tests
10//!
11//! ```bash
12//! cargo test --test proactive_sync_multi
13//! ```
14
15mod common;
16
17use std::time::Duration;
18
19use common::TestRelay;
20use nostr_sdk::prelude::*;
21
22/// Kind 30617 - Repository Announcement (NIP-34)
23const KIND_REPOSITORY_ANNOUNCEMENT: u16 = 30617;
24
25/// Test that sync relay starts successfully when configured with another relay URL
26#[tokio::test]
27async fn test_sync_relay_starts_with_source_url() {
28 // Start source relay (relay_a)
29 let relay_a = TestRelay::start().await;
30
31 // Give relay_a time to start
32 tokio::time::sleep(Duration::from_millis(200)).await;
33
34 // Start syncing relay (relay_sync) configured to sync from relay_a
35 let relay_sync = TestRelay::start_with_sync(Some(relay_a.url().into())).await;
36
37 // Give time for connection establishment
38 tokio::time::sleep(Duration::from_millis(500)).await;
39
40 // If we got here without panic, the relay started successfully with sync config
41 relay_sync.stop().await;
42 relay_a.stop().await;
43}
44
45/// Test that relay starts successfully without sync URL (discovery mode)
46#[tokio::test]
47async fn test_relay_starts_without_sync_url() {
48 // Start a regular relay (no sync configured)
49 let relay = TestRelay::start().await;
50
51 // Give relay time to start
52 tokio::time::sleep(Duration::from_millis(300)).await;
53
54 // Verify we can connect to it
55 let client = Client::default();
56 client
57 .add_relay(relay.url())
58 .await
59 .expect("Failed to add relay");
60 client.connect().await;
61
62 // If we got here, the relay is running
63 client.disconnect().await;
64 relay.stop().await;
65}
66
67/// Test that multiple relays can start independently
68#[tokio::test]
69async fn test_multiple_independent_relays() {
70 // Start three independent relays
71 let relay_a = TestRelay::start().await;
72 let relay_b = TestRelay::start().await;
73 let relay_c = TestRelay::start().await;
74
75 // Give time for all to start
76 tokio::time::sleep(Duration::from_millis(300)).await;
77
78 // Verify all have unique URLs
79 assert_ne!(relay_a.url(), relay_b.url());
80 assert_ne!(relay_b.url(), relay_c.url());
81 assert_ne!(relay_a.url(), relay_c.url());
82
83 // Verify all have unique domains
84 assert_ne!(relay_a.domain(), relay_b.domain());
85 assert_ne!(relay_b.domain(), relay_c.domain());
86 assert_ne!(relay_a.domain(), relay_c.domain());
87
88 // Clean up
89 relay_c.stop().await;
90 relay_b.stop().await;
91 relay_a.stop().await;
92}
93
94/// Test that events can be sent to a source relay
95#[tokio::test]
96async fn test_event_submission_to_relay() {
97 // Start relay
98 let relay = TestRelay::start().await;
99 tokio::time::sleep(Duration::from_millis(200)).await;
100
101 // Create test keys
102 let keys = Keys::generate();
103
104 // Create a simple announcement-like event (kind 30617)
105 // Note: This tests event submission, not full announcement validation
106 let tags = vec![
107 Tag::identifier("test-repo"),
108 Tag::custom(
109 TagKind::custom("clone"),
110 vec![format!("http://{}/test-repo", relay.domain())],
111 ),
112 Tag::custom(
113 TagKind::custom("relays"),
114 vec![format!("ws://{}", relay.domain())],
115 ),
116 ];
117
118 let event = EventBuilder::new(
119 Kind::Custom(KIND_REPOSITORY_ANNOUNCEMENT),
120 "Test repository",
121 )
122 .tags(tags)
123 .sign_with_keys(&keys)
124 .expect("Failed to sign event");
125
126 // Try to send event to relay
127 let client = Client::default();
128 client
129 .add_relay(relay.url())
130 .await
131 .expect("Failed to add relay");
132 client.connect().await;
133
134 // Send event - it may or may not be accepted depending on validation
135 // The point is the connection and submission work
136 let result = client.send_event(&event).await;
137
138 // Clean up
139 client.disconnect().await;
140 relay.stop().await;
141
142 // Verify send completed (success or rejection is fine, no transport error)
143 assert!(result.is_ok() || result.is_err());
144}
145
146/// Test domain extraction from relay URL (unit test style)
147#[test]
148fn test_domain_extraction() {
149 // This tests the domain() method of TestRelay indirectly
150 // by verifying the format matches expectations
151
152 // Domain should be in format "127.0.0.1:PORT"
153 let example_domain = "127.0.0.1:8080";
154 assert!(example_domain.starts_with("127.0.0.1:"));
155
156 // URL should be in format "ws://127.0.0.1:PORT"
157 let example_url = "ws://127.0.0.1:8080";
158 assert!(example_url.starts_with("ws://127.0.0.1:"));
159}
160
161/// Test that sync configuration is properly passed to relay process
162#[tokio::test]
163async fn test_sync_configuration_applied() {
164 // Start source relay
165 let relay_source = TestRelay::start().await;
166 tokio::time::sleep(Duration::from_millis(200)).await;
167
168 // Start syncing relay with explicit sync URL
169 let relay_sync = TestRelay::start_with_sync(Some(relay_source.url().into())).await;
170 tokio::time::sleep(Duration::from_millis(300)).await;
171
172 // Both relays should be running
173 // The sync relay has NGIT_SYNC_BOOTSTRAP_RELAY_URL set (verified by relay starting)
174
175 let client_source = Client::default();
176 client_source
177 .add_relay(relay_source.url())
178 .await
179 .expect("Failed to add source relay");
180 client_source.connect().await;
181
182 let client_sync = Client::default();
183 client_sync
184 .add_relay(relay_sync.url())
185 .await
186 .expect("Failed to add sync relay");
187 client_sync.connect().await;
188
189 // Both should be accessible
190 client_sync.disconnect().await;
191 client_source.disconnect().await;
192 relay_sync.stop().await;
193 relay_source.stop().await;
194}
diff --git a/tests/proactive_sync_resilience.rs b/tests/proactive_sync_resilience.rs
deleted file mode 100644
index 60b18dd..0000000
--- a/tests/proactive_sync_resilience.rs
+++ /dev/null
@@ -1,476 +0,0 @@
1//! Integration tests for GRASP-02 Phase 3: Resilience & Health Tracking
2//!
3//! Tests verify:
4//! - Exponential backoff on connection failures (5s → 1h max)
5//! - Dead relay detection after 24h of failures
6//! - Successful connection resets to Healthy
7//! - Dead relays retry minimally (once per day)
8//! - Health state tracking is thread-safe
9
10use std::time::{Duration, Instant};
11
12use ngit_grasp::sync::health::{HealthState, RelayHealthTracker};
13
14/// Test that a single failure transitions relay to Degraded state
15#[test]
16fn test_single_failure_causes_degraded_state() {
17 let tracker = RelayHealthTracker::with_defaults();
18 let url = "wss://test-relay.example.com";
19
20 // Initial state should allow connection
21 assert!(tracker.should_attempt_connection(url));
22
23 // Record a failure
24 tracker.record_failure(url);
25
26 // Should be in degraded state
27 assert_eq!(tracker.get_state(url), HealthState::Degraded);
28 assert_eq!(tracker.get_failure_count(url), 1);
29}
30
31/// Test that successful connection resets to Healthy state
32#[test]
33fn test_success_resets_to_healthy() {
34 let tracker = RelayHealthTracker::with_defaults();
35 let url = "wss://test-relay.example.com";
36
37 // Simulate multiple failures
38 tracker.record_failure(url);
39 tracker.record_failure(url);
40 tracker.record_failure(url);
41
42 assert_eq!(tracker.get_state(url), HealthState::Degraded);
43 assert_eq!(tracker.get_failure_count(url), 3);
44
45 // Success should reset everything
46 tracker.record_success(url);
47
48 assert_eq!(tracker.get_state(url), HealthState::Healthy);
49 assert_eq!(tracker.get_failure_count(url), 0);
50 assert!(tracker.should_attempt_connection(url));
51}
52
53/// Test that backoff increases exponentially
54#[test]
55fn test_exponential_backoff_calculation() {
56 let max_backoff = 3600u64; // 1 hour
57
58 // failure 1: 5s (5 * 2^0)
59 assert_eq!(
60 RelayHealthTracker::get_backoff_duration(1, max_backoff),
61 Duration::from_secs(5)
62 );
63
64 // failure 2: 10s (5 * 2^1)
65 assert_eq!(
66 RelayHealthTracker::get_backoff_duration(2, max_backoff),
67 Duration::from_secs(10)
68 );
69
70 // failure 3: 20s (5 * 2^2)
71 assert_eq!(
72 RelayHealthTracker::get_backoff_duration(3, max_backoff),
73 Duration::from_secs(20)
74 );
75
76 // failure 4: 40s (5 * 2^3)
77 assert_eq!(
78 RelayHealthTracker::get_backoff_duration(4, max_backoff),
79 Duration::from_secs(40)
80 );
81
82 // failure 5: 80s (5 * 2^4)
83 assert_eq!(
84 RelayHealthTracker::get_backoff_duration(5, max_backoff),
85 Duration::from_secs(80)
86 );
87
88 // failure 6: 160s (5 * 2^5)
89 assert_eq!(
90 RelayHealthTracker::get_backoff_duration(6, max_backoff),
91 Duration::from_secs(160)
92 );
93
94 // failure 7: 320s (5 * 2^6)
95 assert_eq!(
96 RelayHealthTracker::get_backoff_duration(7, max_backoff),
97 Duration::from_secs(320)
98 );
99
100 // failure 8: 640s (5 * 2^7)
101 assert_eq!(
102 RelayHealthTracker::get_backoff_duration(8, max_backoff),
103 Duration::from_secs(640)
104 );
105
106 // failure 9: 1280s (5 * 2^8)
107 assert_eq!(
108 RelayHealthTracker::get_backoff_duration(9, max_backoff),
109 Duration::from_secs(1280)
110 );
111
112 // failure 10: 2560s (5 * 2^9)
113 assert_eq!(
114 RelayHealthTracker::get_backoff_duration(10, max_backoff),
115 Duration::from_secs(2560)
116 );
117}
118
119/// Test that backoff is capped at max_backoff
120#[test]
121fn test_backoff_capped_at_maximum() {
122 let max_backoff = 3600u64; // 1 hour
123
124 // After many failures, should cap at max_backoff
125 assert_eq!(
126 RelayHealthTracker::get_backoff_duration(15, max_backoff),
127 Duration::from_secs(max_backoff)
128 );
129
130 assert_eq!(
131 RelayHealthTracker::get_backoff_duration(20, max_backoff),
132 Duration::from_secs(max_backoff)
133 );
134
135 assert_eq!(
136 RelayHealthTracker::get_backoff_duration(100, max_backoff),
137 Duration::from_secs(max_backoff)
138 );
139}
140
141/// Test that custom max_backoff is respected
142#[test]
143fn test_custom_max_backoff() {
144 let custom_max = 60u64; // 1 minute max
145
146 // After several failures, should cap at custom max
147 assert_eq!(
148 RelayHealthTracker::get_backoff_duration(10, custom_max),
149 Duration::from_secs(custom_max)
150 );
151
152 // Tracker with custom max should use it
153 let tracker = RelayHealthTracker::with_max_backoff(custom_max);
154 let url = "wss://test-relay.example.com";
155
156 // Simulate many failures
157 for _ in 0..20 {
158 tracker.record_failure(url);
159 }
160
161 // Should still be degraded (not dead without 24h)
162 assert_eq!(tracker.get_state(url), HealthState::Degraded);
163}
164
165/// Test that backoff blocks immediate reconnection
166#[test]
167fn test_backoff_blocks_immediate_reconnection() {
168 let tracker = RelayHealthTracker::with_defaults();
169 let url = "wss://test-relay.example.com";
170
171 // First connection attempt should be allowed
172 assert!(tracker.should_attempt_connection(url));
173
174 // Record a failure
175 tracker.record_failure(url);
176
177 // Immediately after failure, connection should be blocked (backoff active)
178 assert!(!tracker.should_attempt_connection(url));
179
180 // Should have remaining backoff
181 let remaining = tracker.get_remaining_backoff(url);
182 assert!(remaining.is_some());
183 assert!(remaining.unwrap() > Duration::ZERO);
184}
185
186/// Test that multiple relays are tracked independently
187#[test]
188fn test_multiple_relays_independent() {
189 let tracker = RelayHealthTracker::with_defaults();
190 let url1 = "wss://relay1.example.com";
191 let url2 = "wss://relay2.example.com";
192 let url3 = "wss://relay3.example.com";
193
194 // Fail relay1 multiple times
195 tracker.record_failure(url1);
196 tracker.record_failure(url1);
197 tracker.record_failure(url1);
198
199 // Succeed on relay2
200 tracker.record_success(url2);
201
202 // Fail relay3 once
203 tracker.record_failure(url3);
204
205 // Verify independent states
206 assert_eq!(tracker.get_state(url1), HealthState::Degraded);
207 assert_eq!(tracker.get_failure_count(url1), 3);
208
209 assert_eq!(tracker.get_state(url2), HealthState::Healthy);
210 assert_eq!(tracker.get_failure_count(url2), 0);
211
212 assert_eq!(tracker.get_state(url3), HealthState::Degraded);
213 assert_eq!(tracker.get_failure_count(url3), 1);
214}
215
216/// Test is_dead returns false for degraded relays
217#[test]
218fn test_is_dead_false_for_degraded() {
219 let tracker = RelayHealthTracker::with_defaults();
220 let url = "wss://test-relay.example.com";
221
222 // Simulate failures
223 for _ in 0..10 {
224 tracker.record_failure(url);
225 }
226
227 // Should be degraded but not dead (24h hasn't passed)
228 assert_eq!(tracker.get_state(url), HealthState::Degraded);
229 assert!(!tracker.is_dead(url));
230}
231
232/// Test get_tracked_relays returns all tracked URLs
233#[test]
234fn test_get_tracked_relays() {
235 let tracker = RelayHealthTracker::with_defaults();
236
237 // Track multiple relays
238 tracker.record_success("wss://relay1.example.com");
239 tracker.record_failure("wss://relay2.example.com");
240 tracker.record_success("wss://relay3.example.com");
241
242 let tracked = tracker.get_tracked_relays();
243 assert_eq!(tracked.len(), 3);
244 assert!(tracked.contains(&"wss://relay1.example.com".to_string()));
245 assert!(tracked.contains(&"wss://relay2.example.com".to_string()));
246 assert!(tracked.contains(&"wss://relay3.example.com".to_string()));
247}
248
249/// Test get_health returns cloned health info
250#[test]
251fn test_get_health_returns_clone() {
252 let tracker = RelayHealthTracker::with_defaults();
253 let url = "wss://test-relay.example.com";
254
255 // Record success
256 tracker.record_success(url);
257
258 // Get health info
259 let health = tracker.get_health(url);
260 assert!(health.is_some());
261
262 let health = health.unwrap();
263 assert_eq!(health.state, HealthState::Healthy);
264 assert!(health.last_success_time.is_some());
265 assert_eq!(health.consecutive_failures, 0);
266}
267
268/// Test get_health returns None for non-existent relay
269#[test]
270fn test_get_health_nonexistent() {
271 let tracker = RelayHealthTracker::with_defaults();
272
273 let health = tracker.get_health("wss://nonexistent.example.com");
274 assert!(health.is_none());
275}
276
277/// Test that new relays default to allowing connection
278#[test]
279fn test_new_relay_allows_connection() {
280 let tracker = RelayHealthTracker::with_defaults();
281
282 // A never-seen relay should allow connection
283 assert!(tracker.should_attempt_connection("wss://brand-new-relay.example.com"));
284}
285
286/// Test health state display
287#[test]
288fn test_health_state_display() {
289 assert_eq!(HealthState::Healthy.to_string(), "healthy");
290 assert_eq!(HealthState::Degraded.to_string(), "degraded");
291 assert_eq!(HealthState::Dead.to_string(), "dead");
292}
293
294/// Test thread safety with concurrent access
295#[tokio::test]
296async fn test_concurrent_health_tracking() {
297 use std::sync::Arc;
298
299 let tracker = Arc::new(RelayHealthTracker::with_defaults());
300 let url = "wss://concurrent-test-relay.example.com";
301
302 // Spawn multiple tasks that access the tracker concurrently
303 let mut handles = vec![];
304
305 for i in 0..10 {
306 let tracker_clone = tracker.clone();
307 let url_owned = url.to_string();
308 let handle = tokio::spawn(async move {
309 if i % 2 == 0 {
310 tracker_clone.record_failure(&url_owned);
311 } else {
312 tracker_clone.record_success(&url_owned);
313 }
314 tracker_clone.get_state(&url_owned);
315 tracker_clone.should_attempt_connection(&url_owned);
316 });
317 handles.push(handle);
318 }
319
320 // Wait for all tasks
321 for handle in handles {
322 handle.await.unwrap();
323 }
324
325 // Tracker should still be usable
326 let health = tracker.get_health(url);
327 assert!(health.is_some());
328}
329
330/// Test that failure streak tracking works correctly
331#[test]
332fn test_failure_streak_tracking() {
333 let tracker = RelayHealthTracker::with_defaults();
334 let url = "wss://test-relay.example.com";
335
336 // Build up a failure streak
337 for i in 1..=5 {
338 tracker.record_failure(url);
339 assert_eq!(tracker.get_failure_count(url), i);
340 }
341
342 // Success should reset the streak
343 tracker.record_success(url);
344 assert_eq!(tracker.get_failure_count(url), 0);
345
346 // Start a new streak
347 tracker.record_failure(url);
348 assert_eq!(tracker.get_failure_count(url), 1);
349}
350
351/// Test recovery from degraded state
352#[test]
353fn test_recovery_from_degraded() {
354 let tracker = RelayHealthTracker::with_defaults();
355 let url = "wss://test-relay.example.com";
356
357 // Enter degraded state
358 tracker.record_failure(url);
359 assert_eq!(tracker.get_state(url), HealthState::Degraded);
360
361 // Recover
362 tracker.record_success(url);
363 assert_eq!(tracker.get_state(url), HealthState::Healthy);
364 assert!(tracker.should_attempt_connection(url));
365 assert!(tracker.get_remaining_backoff(url).is_none());
366}
367
368/// Test that remaining backoff is None after success
369#[test]
370fn test_no_remaining_backoff_after_success() {
371 let tracker = RelayHealthTracker::with_defaults();
372 let url = "wss://test-relay.example.com";
373
374 // Fail to set backoff
375 tracker.record_failure(url);
376 assert!(tracker.get_remaining_backoff(url).is_some());
377
378 // Succeed to clear backoff
379 tracker.record_success(url);
380 assert!(tracker.get_remaining_backoff(url).is_none());
381}
382
383/// Integration test: simulate a realistic connection lifecycle
384#[test]
385fn test_realistic_connection_lifecycle() {
386 let tracker = RelayHealthTracker::with_max_backoff(60); // 1 minute max for test
387 let url = "wss://production-relay.example.com";
388
389 // Initial connection succeeds
390 tracker.record_success(url);
391 assert_eq!(tracker.get_state(url), HealthState::Healthy);
392
393 // Connection drops - first failure
394 tracker.record_failure(url);
395 assert_eq!(tracker.get_state(url), HealthState::Degraded);
396 assert_eq!(tracker.get_failure_count(url), 1);
397
398 // Second failure (retry failed)
399 tracker.record_failure(url);
400 assert_eq!(tracker.get_failure_count(url), 2);
401
402 // Third failure
403 tracker.record_failure(url);
404 assert_eq!(tracker.get_failure_count(url), 3);
405
406 // Connection finally succeeds
407 tracker.record_success(url);
408 assert_eq!(tracker.get_state(url), HealthState::Healthy);
409 assert_eq!(tracker.get_failure_count(url), 0);
410 assert!(tracker.should_attempt_connection(url));
411}
412
413/// Test backoff timing sequence
414#[test]
415fn test_backoff_timing_sequence() {
416 // With default max of 3600s (1 hour), verify the progression
417 let max = 3600u64;
418
419 let expected = vec![
420 (1, 5), // 5s
421 (2, 10), // 10s
422 (3, 20), // 20s
423 (4, 40), // 40s
424 (5, 80), // 80s
425 (6, 160), // 160s (~2.7 min)
426 (7, 320), // 320s (~5.3 min)
427 (8, 640), // 640s (~10.7 min)
428 (9, 1280), // 1280s (~21.3 min)
429 (10, 2560), // 2560s (~42.7 min)
430 (11, 3600), // capped at 3600s (1 hour)
431 (12, 3600), // still capped
432 ];
433
434 for (failures, expected_secs) in expected {
435 assert_eq!(
436 RelayHealthTracker::get_backoff_duration(failures, max),
437 Duration::from_secs(expected_secs),
438 "Failed for {} failures",
439 failures
440 );
441 }
442}
443
444/// Test that health info timestamp tracking works
445#[test]
446fn test_timestamp_tracking() {
447 let tracker = RelayHealthTracker::with_defaults();
448 let url = "wss://test-relay.example.com";
449
450 // Record initial success
451 let before = Instant::now();
452 tracker.record_success(url);
453 let after = Instant::now();
454
455 let health = tracker.get_health(url).unwrap();
456 let success_time = health.last_success_time.unwrap();
457
458 // Success time should be between before and after
459 assert!(success_time >= before);
460 assert!(success_time <= after);
461
462 // Record failure
463 let before_fail = Instant::now();
464 tracker.record_failure(url);
465 let after_fail = Instant::now();
466
467 let health = tracker.get_health(url).unwrap();
468 let failure_time = health.last_failure_time.unwrap();
469 let first_failure = health.first_failure_time.unwrap();
470
471 // Failure times should be between before and after
472 assert!(failure_time >= before_fail);
473 assert!(failure_time <= after_fail);
474 assert!(first_failure >= before_fail);
475 assert!(first_failure <= after_fail);
476} \ No newline at end of file