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-02-23 15:41:32 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-02-23 15:41:32 +0000
commitc54ce061d6d278cce8362d5af085808ca60c239b (patch)
treeec967d6195d9f7ec4f061449596611afe3a0950f /tests
parente0ad39a489b3398f8208713bf728db0cb11475b0 (diff)
parent113928aa84894ea8f65c247d9987527e792b32a9 (diff)
feat: announcement purgatory
Extends purgatory to hold repository announcements until git data arrives, preventing empty repositories from being served to clients. When an announcement is received, a bare repo is created immediately and the announcement is held in purgatory. It is only promoted and served once a git push confirms real content exists. If no push arrives before expiry, the bare repo is deleted and the announcement is silently discarded. Key behaviours: - Soft expiry: announcements are hidden from clients but kept alive while git pushes are in progress, reviving on successful push - Expiry is extended when a matching state event or git push is observed - NIP-09 deletion events remove announcements from purgatory - Purgatory state (announcements, state events, PR events, expired set) is persisted to disk on graceful shutdown and restored on startup, with elapsed downtime subtracted from expiry deadlines - Purgatory announcements drive StateOnly sync in the sync system so state events are fetched from listed relays before promotion - SyncLevel added to RepoSyncIndex to distinguish purgatory repos (StateOnly) from promoted repos (Full L2+L3 sync)
Diffstat (limited to 'tests')
-rw-r--r--tests/archive_grasp_services.rs225
-rw-r--r--tests/archive_read_only.rs368
-rw-r--r--tests/common/purgatory_helpers.rs38
-rw-r--r--tests/common/relay.rs13
-rw-r--r--tests/common/sync_helpers.rs423
-rw-r--r--tests/nip77_negentropy.rs69
-rw-r--r--tests/purgatory.rs89
-rw-r--r--tests/purgatory_persistence.rs157
-rw-r--r--tests/purgatory_sync.rs365
-rw-r--r--tests/sync/discovery.rs259
-rw-r--r--tests/sync/historic_sync.rs32
-rw-r--r--tests/sync/live_sync.rs119
-rw-r--r--tests/sync/maintainer_reprocessing.rs278
-rw-r--r--tests/sync/metrics.rs139
-rw-r--r--tests/sync/mod.rs10
-rw-r--r--tests/sync/tag_variations.rs244
16 files changed, 1536 insertions, 1292 deletions
diff --git a/tests/archive_grasp_services.rs b/tests/archive_grasp_services.rs
index a47fc55..9f13d2a 100644
--- a/tests/archive_grasp_services.rs
+++ b/tests/archive_grasp_services.rs
@@ -29,7 +29,11 @@
29 29
30mod common; 30mod common;
31 31
32use common::TestRelay; 32use common::{
33 check_ref_at_commit, create_repo_announcement, create_state_event,
34 create_test_repo_with_commit, push_to_relay, wait_for_event_served, wait_for_sync_connection,
35 CommitVariant, TestRelay,
36};
33use nostr_sdk::prelude::*; 37use nostr_sdk::prelude::*;
34use std::path::PathBuf; 38use std::path::PathBuf;
35use std::process::{Child, Command, Stdio}; 39use std::process::{Child, Command, Stdio};
@@ -376,3 +380,222 @@ async fn test_archive_multiple_grasp_services() {
376 let _ = process.kill(); 380 let _ = process.kill();
377 let _ = process.wait(); 381 let _ = process.wait();
378} 382}
383
384/// Test that archive_read_only mode creates bare git repositories and syncs data
385/// via relay-to-relay sync (purgatory sync infrastructure).
386///
387/// Scenario:
388/// 1. Start source relay with full repository (announcement + state + git data)
389/// 2. Start archive relay with archive_all=true, archive_read_only=true, syncing from source
390/// 3. Archive relay syncs announcement and state events from source
391/// 4. State events trigger purgatory sync which fetches git data from source's clone URL
392/// 5. Verify bare repository is created and git data is synced
393/// 6. Verify git pushes are rejected (read-only mode)
394#[tokio::test]
395async fn test_archive_read_only_creates_bare_repo() {
396 // 1. Start source relay
397 let source_relay = TestRelay::start().await;
398 let keys = Keys::generate();
399 let identifier = "archive-test-repo";
400
401 // Pre-allocate archive relay port so we can include it in announcement
402 let archive_port = TestRelay::find_free_port();
403 let archive_domain = format!("127.0.0.1:{}", archive_port);
404
405 // 2. Create test repository locally with deterministic commit
406 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
407 let commit_hash = create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest)
408 .expect("Failed to create test repo");
409
410 let npub = keys.public_key().to_bech32().expect("Failed to get npub");
411
412 // 3. Create and send announcement listing BOTH relays
413 // This ensures the archive relay will accept the state event when it syncs
414 let announcement = create_repo_announcement(
415 &keys,
416 &[&source_relay.domain(), &archive_domain],
417 identifier,
418 );
419
420 let source_client = Client::new(keys.clone());
421 source_client
422 .add_relay(source_relay.url())
423 .await
424 .expect("Failed to add source relay");
425 source_client.connect().await;
426
427 // Wait for connection
428 tokio::time::sleep(Duration::from_millis(500)).await;
429
430 // Send announcement to source relay
431 source_client
432 .send_event(&announcement)
433 .await
434 .expect("Failed to send announcement to source");
435
436 tokio::time::sleep(Duration::from_millis(200)).await;
437
438 // 4. Create and send state event
439 let clone_urls = [
440 format!(
441 "http://{}/{}/{}.git",
442 source_relay.domain(),
443 npub,
444 identifier
445 ),
446 format!("http://{}/{}/{}.git", archive_domain, npub, identifier),
447 ];
448 let relay_urls = [
449 source_relay.url().to_string(),
450 format!("ws://{}", archive_domain),
451 ];
452
453 let state_event = create_state_event(
454 &keys,
455 identifier,
456 &[("main", &commit_hash)],
457 &[],
458 &[&clone_urls[0], &clone_urls[1]],
459 &[&relay_urls[0], &relay_urls[1]],
460 )
461 .expect("Failed to create state event");
462
463 let state_event_id = state_event.id;
464
465 // Send state event to source relay (goes to purgatory - no git data yet)
466 source_client
467 .send_event(&state_event)
468 .await
469 .expect("Failed to send state event to source");
470
471 tokio::time::sleep(Duration::from_millis(200)).await;
472
473 // 5. Push git data to source relay
474 // The state event in purgatory authorizes this push
475 push_to_relay(temp_dir.path(), &source_relay.domain(), &npub, identifier)
476 .expect("Push to source should succeed");
477
478 // After push, state event should be released from purgatory on source relay
479 wait_for_event_served(source_relay.url(), &state_event_id, Duration::from_secs(5))
480 .await
481 .expect("State event should be served on source relay after push");
482
483 // 6. Start archive relay with archive_all=true, archive_read_only=true, syncing from source
484 let archive_relay = TestRelay::start_with_archive_and_sync(
485 archive_port,
486 Some(source_relay.url().to_string()),
487 false, // negentropy enabled
488 true, // archive_all
489 true, // archive_read_only
490 )
491 .await;
492
493 // Wait for sync connection to establish
494 wait_for_sync_connection(archive_relay.url(), 1, Duration::from_secs(5))
495 .await
496 .expect("Sync connection should establish");
497
498 // 7. Wait for state event to be released on archive relay
499 // The sync should:
500 // a) Fetch the announcement and state event from source relay
501 // b) Accept announcement (creates bare repo structure) - via archive mode
502 // c) Put state event in purgatory (git data missing on archive relay)
503 // d) Fetch git data from source relay's clone URL
504 // e) Release the state event from purgatory
505
506 let found = wait_for_event_served(
507 archive_relay.url(),
508 &state_event_id,
509 Duration::from_secs(30), // Allow time for sync + git fetch
510 )
511 .await;
512
513 assert!(
514 found.is_ok(),
515 "State event should be served after sync fetches git data: {:?}",
516 found.err()
517 );
518
519 // 8. Verify bare repository was created
520 let repo_path = archive_relay
521 .git_data_path()
522 .join(format!("{}/{}.git", npub, identifier));
523
524 assert!(
525 repo_path.exists(),
526 "Bare repository should be created at {:?} for archive announcement",
527 repo_path
528 );
529
530 // 9. Verify it's a bare repository (check for config file with bare = true)
531 let config_path = repo_path.join("config");
532 assert!(
533 config_path.exists(),
534 "Git config should exist at {:?}",
535 config_path
536 );
537
538 let config_content = tokio::fs::read_to_string(&config_path)
539 .await
540 .expect("Should read git config");
541 assert!(
542 config_content.contains("bare = true"),
543 "Repository at {:?} should be bare (config should contain 'bare = true')",
544 repo_path
545 );
546
547 // 10. Verify refs are correct on archive relay
548 let ref_correct = check_ref_at_commit(
549 &archive_domain,
550 &npub,
551 identifier,
552 "refs/heads/main",
553 &commit_hash,
554 )
555 .await
556 .expect("Failed to check ref");
557
558 assert!(ref_correct, "main branch should point to correct commit");
559
560 // 11. Verify git pushes are rejected (read-only mode)
561 // Create a new commit in the source repo
562 tokio::fs::write(temp_dir.path().join("new_file.txt"), "new content")
563 .await
564 .expect("Failed to write new file");
565
566 let output = tokio::process::Command::new("git")
567 .args(["add", "."])
568 .current_dir(temp_dir.path())
569 .output()
570 .await
571 .expect("Failed to git add");
572 assert!(output.status.success());
573
574 let output = tokio::process::Command::new("git")
575 .args(["commit", "-m", "New commit for push test"])
576 .current_dir(temp_dir.path())
577 .output()
578 .await
579 .expect("Failed to git commit");
580 assert!(output.status.success());
581
582 // Try to push to archive relay (should fail in read-only mode)
583 let push_url = format!("http://{}/{}/{}.git", archive_domain, npub, identifier);
584 let output = tokio::process::Command::new("git")
585 .args(["push", &push_url, "main"])
586 .current_dir(temp_dir.path())
587 .output()
588 .await
589 .expect("Failed to run git push");
590
591 assert!(
592 !output.status.success(),
593 "Git push should be rejected in archive_read_only mode. stderr: {}",
594 String::from_utf8_lossy(&output.stderr)
595 );
596
597 // Cleanup
598 source_client.disconnect().await;
599 archive_relay.stop().await;
600 source_relay.stop().await;
601}
diff --git a/tests/archive_read_only.rs b/tests/archive_read_only.rs
deleted file mode 100644
index be6959b..0000000
--- a/tests/archive_read_only.rs
+++ /dev/null
@@ -1,368 +0,0 @@
1//! Archive Read-Only Mode Integration Tests
2//!
3//! Tests that verify archive_read_only mode behavior:
4//! - Bare git repositories are created for announcements
5//! - Git data is synced via relay-to-relay sync (purgatory sync)
6//! - Git pushes are rejected (read-only mode)
7//!
8//! # Test Strategy
9//!
10//! These tests verify the GRASP-05 archive mode with read_only flag:
11//! 1. Source relay has full repository (announcement + state events + git data)
12//! 2. Archive relay syncs from source relay (relay-to-relay sync)
13//! 3. State events trigger purgatory sync which fetches git data
14//! 4. Git data is validated against Nostr state events
15//! 5. Git pushes are rejected (read-only enforcement)
16//!
17//! # Security Model
18//!
19//! Archive mode uses the existing purgatory sync infrastructure to ensure:
20//! - Git data is validated against Nostr state events
21//! - "Naughty git servers" can't provide incorrect state
22//! - Same security guarantees as normal relay operation
23//!
24//! # Running Tests
25//!
26//! ```bash
27//! # Run all archive read-only tests
28//! cargo test --test archive_read_only
29//!
30//! # Run specific test
31//! cargo test --test archive_read_only test_archive_read_only_creates_bare_repo
32//!
33//! # With output for debugging
34//! cargo test --test archive_read_only -- --nocapture
35//! ```
36
37mod common;
38
39use common::{
40 check_ref_at_commit, create_repo_announcement, create_state_event,
41 create_test_repo_with_commit, push_to_relay, wait_for_event_served, wait_for_sync_connection,
42 CommitVariant, TestRelay,
43};
44use nostr_sdk::prelude::*;
45use std::time::Duration;
46
47/// Test that archive_read_only mode creates bare git repositories and syncs data
48/// via relay-to-relay sync (purgatory sync infrastructure).
49///
50/// Scenario:
51/// 1. Start source relay with full repository (announcement + state + git data)
52/// 2. Start archive relay with archive_all=true, archive_read_only=true, syncing from source
53/// 3. Archive relay syncs announcement and state events from source
54/// 4. State events trigger purgatory sync which fetches git data from source's clone URL
55/// 5. Verify bare repository is created and git data is synced
56/// 6. Verify git pushes are rejected (read-only mode)
57#[tokio::test]
58async fn test_archive_read_only_creates_bare_repo() {
59 // 1. Start source relay
60 let source_relay = TestRelay::start().await;
61 let keys = Keys::generate();
62 let identifier = "archive-test-repo";
63
64 // Pre-allocate archive relay port so we can include it in announcement
65 let archive_port = TestRelay::find_free_port();
66 let archive_domain = format!("127.0.0.1:{}", archive_port);
67
68 // 2. Create test repository locally with deterministic commit
69 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
70 let commit_hash = create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest)
71 .expect("Failed to create test repo");
72
73 let npub = keys.public_key().to_bech32().expect("Failed to get npub");
74
75 // 3. Create and send announcement listing BOTH relays
76 // This ensures the archive relay will accept the state event when it syncs
77 let announcement = create_repo_announcement(
78 &keys,
79 &[&source_relay.domain(), &archive_domain],
80 identifier,
81 );
82
83 let source_client = Client::new(keys.clone());
84 source_client
85 .add_relay(source_relay.url())
86 .await
87 .expect("Failed to add source relay");
88 source_client.connect().await;
89
90 // Wait for connection
91 tokio::time::sleep(Duration::from_millis(500)).await;
92
93 // Send announcement to source relay
94 source_client
95 .send_event(&announcement)
96 .await
97 .expect("Failed to send announcement to source");
98
99 tokio::time::sleep(Duration::from_millis(200)).await;
100
101 // 4. Create and send state event
102 let clone_urls = [
103 format!(
104 "http://{}/{}/{}.git",
105 source_relay.domain(),
106 npub,
107 identifier
108 ),
109 format!("http://{}/{}/{}.git", archive_domain, npub, identifier),
110 ];
111 let relay_urls = [
112 source_relay.url().to_string(),
113 format!("ws://{}", archive_domain),
114 ];
115
116 let state_event = create_state_event(
117 &keys,
118 identifier,
119 &[("main", &commit_hash)],
120 &[],
121 &[&clone_urls[0], &clone_urls[1]],
122 &[&relay_urls[0], &relay_urls[1]],
123 )
124 .expect("Failed to create state event");
125
126 let state_event_id = state_event.id;
127
128 // Send state event to source relay (goes to purgatory - no git data yet)
129 source_client
130 .send_event(&state_event)
131 .await
132 .expect("Failed to send state event to source");
133
134 tokio::time::sleep(Duration::from_millis(200)).await;
135
136 // 5. Push git data to source relay
137 // The state event in purgatory authorizes this push
138 push_to_relay(temp_dir.path(), &source_relay.domain(), &npub, identifier)
139 .expect("Push to source should succeed");
140
141 // After push, state event should be released from purgatory on source relay
142 wait_for_event_served(source_relay.url(), &state_event_id, Duration::from_secs(5))
143 .await
144 .expect("State event should be served on source relay after push");
145
146 // 6. Start archive relay with archive_all=true, archive_read_only=true, syncing from source
147 let archive_relay = TestRelay::start_with_archive_and_sync(
148 archive_port,
149 Some(source_relay.url().to_string()),
150 false, // negentropy enabled
151 true, // archive_all
152 true, // archive_read_only
153 )
154 .await;
155
156 // Wait for sync connection to establish
157 wait_for_sync_connection(archive_relay.url(), 1, Duration::from_secs(5))
158 .await
159 .expect("Sync connection should establish");
160
161 // 7. Wait for state event to be released on archive relay
162 // The sync should:
163 // a) Fetch the announcement and state event from source relay
164 // b) Accept announcement (creates bare repo structure) - via archive mode
165 // c) Put state event in purgatory (git data missing on archive relay)
166 // d) Fetch git data from source relay's clone URL
167 // e) Release the state event from purgatory
168 let found = wait_for_event_served(
169 archive_relay.url(),
170 &state_event_id,
171 Duration::from_secs(30), // Allow time for sync + git fetch
172 )
173 .await;
174
175 assert!(
176 found.is_ok(),
177 "State event should be served after sync fetches git data: {:?}",
178 found.err()
179 );
180
181 // 8. Verify bare repository was created
182 let repo_path = archive_relay
183 .git_data_path()
184 .join(format!("{}/{}.git", npub, identifier));
185
186 assert!(
187 repo_path.exists(),
188 "Bare repository should be created at {:?} for archive announcement",
189 repo_path
190 );
191
192 // 9. Verify it's a bare repository (check for config file with bare = true)
193 let config_path = repo_path.join("config");
194 assert!(
195 config_path.exists(),
196 "Git config should exist at {:?}",
197 config_path
198 );
199
200 let config_content = tokio::fs::read_to_string(&config_path)
201 .await
202 .expect("Should read git config");
203 assert!(
204 config_content.contains("bare = true"),
205 "Repository at {:?} should be bare (config should contain 'bare = true')",
206 repo_path
207 );
208
209 // 10. Verify refs are correct on archive relay
210 let ref_correct = check_ref_at_commit(
211 &archive_domain,
212 &npub,
213 identifier,
214 "refs/heads/main",
215 &commit_hash,
216 )
217 .await
218 .expect("Failed to check ref");
219
220 assert!(ref_correct, "main branch should point to correct commit");
221
222 // 11. Verify git pushes are rejected (read-only mode)
223 // Create a new commit in the source repo
224 tokio::fs::write(temp_dir.path().join("new_file.txt"), "new content")
225 .await
226 .expect("Failed to write new file");
227
228 let output = tokio::process::Command::new("git")
229 .args(["add", "."])
230 .current_dir(temp_dir.path())
231 .output()
232 .await
233 .expect("Failed to git add");
234 assert!(output.status.success());
235
236 let output = tokio::process::Command::new("git")
237 .args(["commit", "-m", "New commit for push test"])
238 .current_dir(temp_dir.path())
239 .output()
240 .await
241 .expect("Failed to git commit");
242 assert!(output.status.success());
243
244 // Try to push to archive relay (should fail in read-only mode)
245 let push_url = format!("http://{}/{}/{}.git", archive_domain, npub, identifier);
246 let output = tokio::process::Command::new("git")
247 .args(["push", &push_url, "main"])
248 .current_dir(temp_dir.path())
249 .output()
250 .await
251 .expect("Failed to run git push");
252
253 assert!(
254 !output.status.success(),
255 "Git push should be rejected in archive_read_only mode. stderr: {}",
256 String::from_utf8_lossy(&output.stderr)
257 );
258
259 // Cleanup
260 source_client.disconnect().await;
261 archive_relay.stop().await;
262 source_relay.stop().await;
263}
264
265/// Test that archive mode without state events does NOT sync git data.
266///
267/// This verifies the security model: archive mode only syncs git data
268/// when there are state events to validate against.
269///
270/// Scenario:
271/// 1. Start source relay with announcement only (no state events)
272/// 2. Start archive relay syncing from source
273/// 3. Archive relay syncs announcement (creates bare repo)
274/// 4. Verify git data is NOT synced (no state events to trigger purgatory sync)
275#[tokio::test]
276async fn test_archive_without_state_events_does_not_sync_git() {
277 // 1. Start source relay
278 let source_relay = TestRelay::start().await;
279 let keys = Keys::generate();
280 let identifier = "archive-no-state-repo";
281
282 // Pre-allocate archive relay port
283 let archive_port = TestRelay::find_free_port();
284 let archive_domain = format!("127.0.0.1:{}", archive_port);
285
286 // 2. Create test repository locally
287 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
288 let commit_hash = create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest)
289 .expect("Failed to create test repo");
290
291 let npub = keys.public_key().to_bech32().expect("Failed to get npub");
292
293 // 3. Create and send announcement listing BOTH relays (but NO state event)
294 let announcement = create_repo_announcement(
295 &keys,
296 &[&source_relay.domain(), &archive_domain],
297 identifier,
298 );
299
300 let source_client = Client::new(keys.clone());
301 source_client
302 .add_relay(source_relay.url())
303 .await
304 .expect("Failed to add source relay");
305 source_client.connect().await;
306
307 tokio::time::sleep(Duration::from_millis(500)).await;
308
309 // Send announcement to source relay
310 source_client
311 .send_event(&announcement)
312 .await
313 .expect("Failed to send announcement to source");
314
315 tokio::time::sleep(Duration::from_millis(200)).await;
316
317 // 4. Push git data to source relay (but no state event to authorize it)
318 // This push will fail because there's no state event in purgatory
319 // That's expected - we're testing that archive mode doesn't blindly fetch git data
320
321 // 5. Start archive relay
322 let archive_relay = TestRelay::start_with_archive_and_sync(
323 archive_port,
324 Some(source_relay.url().to_string()),
325 false,
326 true,
327 true,
328 )
329 .await;
330
331 // Wait for sync
332 wait_for_sync_connection(archive_relay.url(), 1, Duration::from_secs(5))
333 .await
334 .expect("Sync connection should establish");
335
336 // Give time for any potential git sync to happen
337 tokio::time::sleep(Duration::from_secs(3)).await;
338
339 // 6. Verify bare repository was created (announcement was accepted)
340 let repo_path = archive_relay
341 .git_data_path()
342 .join(format!("{}/{}.git", npub, identifier));
343
344 assert!(
345 repo_path.exists(),
346 "Bare repository should be created for archive announcement"
347 );
348
349 // 7. Verify git data was NOT synced (no state events to trigger purgatory sync)
350 // Check that the commit does NOT exist in the archive relay's repo
351 let output = tokio::process::Command::new("git")
352 .args(["cat-file", "-t", &commit_hash])
353 .current_dir(&repo_path)
354 .output()
355 .await;
356
357 let commit_exists = output.map(|o| o.status.success()).unwrap_or(false);
358
359 assert!(
360 !commit_exists,
361 "Git data should NOT be synced without state events (security: validates against Nostr state)"
362 );
363
364 // Cleanup
365 source_client.disconnect().await;
366 archive_relay.stop().await;
367 source_relay.stop().await;
368}
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
355pub 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/common/relay.rs b/tests/common/relay.rs
index 227849a..b1e96cf 100644
--- a/tests/common/relay.rs
+++ b/tests/common/relay.rs
@@ -204,7 +204,7 @@ impl TestRelay {
204 .env("NGIT_GIT_DATA_PATH", git_data_dir.path()) 204 .env("NGIT_GIT_DATA_PATH", git_data_dir.path())
205 .env("NGIT_DATABASE_BACKEND", "memory") // Force in-memory database for isolation 205 .env("NGIT_DATABASE_BACKEND", "memory") // Force in-memory database for isolation
206 .env("NGIT_OWNER_NPUB", &test_npub) 206 .env("NGIT_OWNER_NPUB", &test_npub)
207 .env("NGIT_SYNC_BATCH_WINDOW_MS", "200") // Fast batch window for tests (200ms instead of 5s default) 207 .env("NGIT_TEST", "1") // Enable test mode: fast timers (200ms batch window, 200ms purgatory sync)
208 .env("NGIT_SYNC_STARTUP_DELAY_SECS", "0") // No startup delay for faster tests 208 .env("NGIT_SYNC_STARTUP_DELAY_SECS", "0") // No startup delay for faster tests
209 .env("NGIT_SYNC_STARTUP_JITTER_MS", "0") // No jitter for tests 209 .env("NGIT_SYNC_STARTUP_JITTER_MS", "0") // No jitter for tests
210 .env("NGIT_SYNC_DISCONNECT_CHECK_INTERVAL_SECS", "1") // Fast reconnect attempts for tests 210 .env("NGIT_SYNC_DISCONNECT_CHECK_INTERVAL_SECS", "1") // Fast reconnect attempts for tests
@@ -213,8 +213,15 @@ impl TestRelay {
213 "RUST_LOG", 213 "RUST_LOG",
214 std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string()), 214 std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string()),
215 ) // Use RUST_LOG from environment or default to info 215 ) // Use RUST_LOG from environment or default to info
216 .stdout(Stdio::null()) // Suppress stdout for cleaner test output 216 .stdout(
217 .stderr(Stdio::null()); // Suppress stderr for cleaner test output 217 std::fs::OpenOptions::new()
218 .create(true)
219 .append(true)
220 .open(format!("/tmp/relay-{}.log", port))
221 .map(Stdio::from)
222 .unwrap_or(Stdio::null()),
223 )
224 .stderr(Stdio::inherit()); // Inherit stderr for test output
218 225
219 // Add bootstrap relay URL if provided 226 // Add bootstrap relay URL if provided
220 if let Some(ref bootstrap_url) = bootstrap_relay_url { 227 if let Some(ref bootstrap_url) = bootstrap_relay_url {
diff --git a/tests/common/sync_helpers.rs b/tests/common/sync_helpers.rs
index 5fc2ad7..af51e78 100644
--- a/tests/common/sync_helpers.rs
+++ b/tests/common/sync_helpers.rs
@@ -507,41 +507,53 @@ fn check_sync_connections_in_metrics(metrics: &str, expected: usize) -> bool {
507/// assert!(found, "Expected event {} to sync to relay", event.id); 507/// assert!(found, "Expected event {} to sync to relay", event.id);
508/// ``` 508/// ```
509pub async fn wait_for_event_on_relay(relay_url: &str, filter: Filter, timeout: Duration) -> bool { 509pub async fn wait_for_event_on_relay(relay_url: &str, filter: Filter, timeout: Duration) -> bool {
510 // Create a temporary client for querying 510 let deadline = tokio::time::Instant::now() + timeout;
511 let temp_keys = Keys::generate(); 511 let poll_interval = Duration::from_millis(200);
512 let client = Client::new(temp_keys);
513
514 // Try to connect
515 if client.add_relay(relay_url).await.is_err() {
516 return false;
517 }
518 512
519 client.connect().await; 513 loop {
514 // Create a fresh client for each poll attempt (avoids stale connection state)
515 let temp_keys = Keys::generate();
516 let client = Client::new(temp_keys);
520 517
521 // Wait for connection (brief timeout) 518 if client.add_relay(relay_url).await.is_err() {
522 let mut connected = false; 519 if tokio::time::Instant::now() >= deadline {
523 for _ in 0..10 { 520 return false;
524 tokio::time::sleep(Duration::from_millis(100)).await; 521 }
525 let relays = client.relays().await; 522 tokio::time::sleep(poll_interval).await;
526 if relays.values().any(|r| r.is_connected()) { 523 continue;
527 connected = true;
528 break;
529 } 524 }
530 }
531 525
532 if !connected { 526 client.connect().await;
533 client.disconnect().await; 527
534 return false; 528 // Wait for connection
535 } 529 let mut connected = false;
530 for _ in 0..10 {
531 tokio::time::sleep(Duration::from_millis(100)).await;
532 let relays = client.relays().await;
533 if relays.values().any(|r| r.is_connected()) {
534 connected = true;
535 break;
536 }
537 }
536 538
537 // Fetch events with the provided timeout 539 if connected {
538 let result = client.fetch_events(filter, timeout).await; 540 // Use a short fetch window — if the event is there, EOSE comes back quickly
541 let fetch_timeout = Duration::from_millis(500);
542 let result = client.fetch_events(filter.clone(), fetch_timeout).await;
543 client.disconnect().await;
539 544
540 client.disconnect().await; 545 match result {
546 Ok(events) if !events.is_empty() => return true,
547 _ => {}
548 }
549 } else {
550 client.disconnect().await;
551 }
541 552
542 match result { 553 if tokio::time::Instant::now() >= deadline {
543 Ok(events) => !events.is_empty(), 554 return false;
544 Err(_) => false, 555 }
556 tokio::time::sleep(poll_interval).await;
545 } 557 }
546} 558}
547 559
@@ -774,6 +786,11 @@ impl MetricsTestHarness {
774 self.source_relays[idx].domain() 786 self.source_relays[idx].domain()
775 } 787 }
776 788
789 /// Get a reference to a source relay (for advanced test operations)
790 pub fn source_relay(&self, idx: usize) -> &TestRelay {
791 &self.source_relays[idx]
792 }
793
777 /// Submit events to a specific source relay 794 /// Submit events to a specific source relay
778 pub async fn submit_events(&self, source_idx: usize, events: &[Event]) -> Result<(), String> { 795 pub async fn submit_events(&self, source_idx: usize, events: &[Event]) -> Result<(), String> {
779 let relay = &self.source_relays[source_idx]; 796 let relay = &self.source_relays[source_idx];
@@ -1071,12 +1088,16 @@ pub struct SyncTestResult {
1071 pub syncing_relay: TestRelay, 1088 pub syncing_relay: TestRelay,
1072 pub maintainer_keys: Keys, 1089 pub maintainer_keys: Keys,
1073 pub repo_coord: String, 1090 pub repo_coord: String,
1091 // Keep SmartGitServer alive for the test duration
1092 _git_server: Option<super::git_server::SmartGitServer>,
1093 // Keep temp dir alive for the test duration
1094 _git_temp_dir: Option<tempfile::TempDir>,
1074} 1095}
1075 1096
1076/// Helper to send an event to a relay 1097/// Helper to send an event to a relay
1077/// 1098///
1078/// Creates a temporary client, sends the event, and disconnects. 1099/// Creates a temporary client, sends the event, and disconnects.
1079async fn send_to_relay(relay: &TestRelay, event: &Event) -> Result<(), String> { 1100pub async fn send_to_relay(relay: &TestRelay, event: &Event) -> Result<(), String> {
1080 let temp_keys = Keys::generate(); 1101 let temp_keys = Keys::generate();
1081 let client = TestClient::new(relay.url(), temp_keys).await?; 1102 let client = TestClient::new(relay.url(), temp_keys).await?;
1082 client.send_event(event).await?; 1103 client.send_event(event).await?;
@@ -1084,6 +1105,270 @@ async fn send_to_relay(relay: &TestRelay, event: &Event) -> Result<(), String> {
1084 Ok(()) 1105 Ok(())
1085} 1106}
1086 1107
1108/// Helper to send an event to a relay by URL
1109///
1110/// Creates a temporary client, sends the event, and disconnects.
1111pub async fn send_to_relay_url(relay_url: &str, event: &Event) -> Result<(), String> {
1112 let temp_keys = Keys::generate();
1113 let client = TestClient::new(relay_url, temp_keys).await?;
1114 client.send_event(event).await?;
1115 client.disconnect().await;
1116 Ok(())
1117}
1118
1119/// Push git repository data to a relay to release a purgatory-held announcement.
1120///
1121/// Creates a local git repo, sends a state event, and pushes to the relay.
1122/// Use this when you need to build a custom announcement but still need the
1123/// relay to accept it (i.e., release it from purgatory).
1124///
1125/// # Arguments
1126/// * `relay` - The relay to push to
1127/// * `keys` - Keys of the repository owner
1128/// * `identifier` - Repository identifier
1129/// * `domains` - All domains in the announcement (for state event URLs)
1130///
1131/// # Returns
1132/// `tempfile::TempDir` - Keep alive for test duration
1133pub async fn push_git_data_to_relay(
1134 relay: &TestRelay,
1135 keys: &Keys,
1136 identifier: &str,
1137 domains: &[&str],
1138) -> tempfile::TempDir {
1139 use super::purgatory_helpers::{
1140 create_state_event, create_test_repo_with_commit, push_to_relay, CommitVariant,
1141 };
1142
1143 let npub = keys
1144 .public_key()
1145 .to_bech32()
1146 .expect("Failed to convert public key to npub");
1147
1148 // Create local git repo
1149 let git_temp_dir = tempfile::tempdir().expect("Failed to create temp dir for git repo");
1150 let commit_hash = create_test_repo_with_commit(git_temp_dir.path(), CommitVariant::StateTest)
1151 .expect("Failed to create test git repo");
1152
1153 let clone_urls: Vec<String> = domains
1154 .iter()
1155 .map(|d| format!("http://{}/{}/{}.git", d, npub, identifier))
1156 .collect();
1157 let relay_urls: Vec<String> = domains.iter().map(|d| format!("ws://{}", d)).collect();
1158
1159 // Build and send state event with all domains' clone URLs
1160 let state_event = create_state_event(
1161 keys,
1162 identifier,
1163 &[("main", &commit_hash)],
1164 &[],
1165 &clone_urls.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
1166 &relay_urls.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
1167 )
1168 .expect("Failed to create state event");
1169
1170 send_to_relay(relay, &state_event)
1171 .await
1172 .expect("Failed to send state event");
1173
1174 // Git push to relay → releases state event from purgatory, authorizes push
1175 push_to_relay(git_temp_dir.path(), &relay.domain(), &npub, identifier)
1176 .expect("Failed to push git data to relay");
1177
1178 // Brief wait for push processing
1179 tokio::time::sleep(Duration::from_millis(500)).await;
1180
1181 git_temp_dir
1182}
1183
1184/// Like `push_git_data_to_relay` but writes a unique marker file so each call
1185/// produces a distinct commit hash.
1186///
1187/// Use this when multiple callers push to the same relay with the same identifier
1188/// but different keys — identical commit hashes cause git to skip pack transfer,
1189/// which can leave the announcement in purgatory.
1190///
1191/// # Arguments
1192/// * `relay` - The relay to push to
1193/// * `keys` - Keys of the repository owner
1194/// * `identifier` - Repository identifier
1195/// * `domains` - All domains in the announcement (for state event URLs)
1196/// * `unique_seed` - A string written into a `.unique` file to differentiate commits
1197///
1198/// # Returns
1199/// `tempfile::TempDir` - Keep alive for test duration
1200pub async fn push_unique_git_data_to_relay(
1201 relay: &TestRelay,
1202 keys: &Keys,
1203 identifier: &str,
1204 domains: &[&str],
1205 unique_seed: &str,
1206) -> tempfile::TempDir {
1207 use super::purgatory_helpers::{create_state_event, push_to_relay};
1208
1209 let npub = keys
1210 .public_key()
1211 .to_bech32()
1212 .expect("Failed to convert public key to npub");
1213
1214 let git_temp_dir = tempfile::tempdir().expect("Failed to create temp dir for git repo");
1215 let path = git_temp_dir.path();
1216
1217 fn git(path: &std::path::Path, args: &[&str]) {
1218 let status = std::process::Command::new("git")
1219 .args(args)
1220 .current_dir(path)
1221 .env("GIT_AUTHOR_NAME", "Test User")
1222 .env("GIT_AUTHOR_EMAIL", "test@example.com")
1223 .env("GIT_COMMITTER_NAME", "Test User")
1224 .env("GIT_COMMITTER_EMAIL", "test@example.com")
1225 .env("GIT_AUTHOR_DATE", "2024-01-01T00:00:00+00:00")
1226 .env("GIT_COMMITTER_DATE", "2024-01-01T00:00:00+00:00")
1227 .output()
1228 .unwrap_or_else(|e| panic!("git {:?} failed to spawn: {}", args, e));
1229 assert!(
1230 status.status.success(),
1231 "git {:?} failed: {}",
1232 args,
1233 String::from_utf8_lossy(&status.stderr)
1234 );
1235 }
1236
1237 git(path, &["init", "--initial-branch=main"]);
1238 git(path, &["config", "user.email", "test@example.com"]);
1239 git(path, &["config", "user.name", "Test User"]);
1240 git(path, &["config", "commit.gpgsign", "false"]);
1241
1242 // Write a unique file so each maintainer gets a distinct commit hash
1243 std::fs::write(path.join("state_test.txt"), "State test content for purgatory sync")
1244 .expect("write state_test.txt");
1245 std::fs::write(path.join(".unique"), unique_seed).expect("write .unique");
1246 git(path, &["add", "."]);
1247 git(path, &["commit", "-m", "State test commit"]);
1248
1249 let commit_hash = {
1250 let out = std::process::Command::new("git")
1251 .args(["rev-parse", "HEAD"])
1252 .current_dir(path)
1253 .output()
1254 .expect("git rev-parse");
1255 String::from_utf8_lossy(&out.stdout).trim().to_string()
1256 };
1257
1258 let clone_urls: Vec<String> = domains
1259 .iter()
1260 .map(|d| format!("http://{}/{}/{}.git", d, npub, identifier))
1261 .collect();
1262 let relay_urls: Vec<String> = domains.iter().map(|d| format!("ws://{}", d)).collect();
1263
1264 let state_event = create_state_event(
1265 keys,
1266 identifier,
1267 &[("main", &commit_hash)],
1268 &[],
1269 &clone_urls.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
1270 &relay_urls.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
1271 )
1272 .expect("Failed to create state event");
1273
1274 send_to_relay(relay, &state_event)
1275 .await
1276 .expect("Failed to send state event");
1277
1278 push_to_relay(path, &relay.domain(), &npub, identifier)
1279 .expect("Failed to push git data to relay");
1280
1281 tokio::time::sleep(Duration::from_millis(500)).await;
1282
1283 git_temp_dir
1284}
1285
1286/// Set up a repository announcement on a relay with git data so it passes purgatory.
1287///
1288/// With the announcement purgatory feature, announcements (kind 30617) require git
1289/// data before they are promoted to the relay's main DB. This helper:
1290///
1291/// 1. Creates a local git repo with a commit
1292/// 2. Builds an announcement and state event (kind 30618) pointing to the relay
1293/// 3. Sends both to the relay (they go to purgatory)
1294/// 4. Git pushes to the relay → releases both from purgatory immediately
1295/// 5. Returns the announcement event and temp dir (keep alive for test duration)
1296///
1297/// # Arguments
1298/// * `relay` - The relay to set up the announcement on
1299/// * `keys` - Keys to sign the announcement with (repo owner)
1300/// * `domains` - All domains that should be listed in the announcement (including relay.domain())
1301/// * `identifier` - Repository identifier (d-tag)
1302///
1303/// # Returns
1304/// `(Event, tempfile::TempDir)` - The announcement event and temp dir.
1305/// The temp dir MUST be kept alive for the duration of the test.
1306pub async fn setup_announcement_on_relay(
1307 relay: &TestRelay,
1308 keys: &Keys,
1309 domains: &[&str],
1310 identifier: &str,
1311) -> (Event, tempfile::TempDir) {
1312 use super::purgatory_helpers::{
1313 create_state_event, create_test_repo_with_commit, push_to_relay, CommitVariant,
1314 };
1315
1316 let npub = keys
1317 .public_key()
1318 .to_bech32()
1319 .expect("Failed to convert public key to npub");
1320
1321 // Create local git repo with a commit
1322 let git_temp_dir = tempfile::tempdir().expect("Failed to create temp dir for git repo");
1323 let commit_hash = create_test_repo_with_commit(git_temp_dir.path(), CommitVariant::StateTest)
1324 .expect("Failed to create test git repo");
1325
1326 // Build clone URLs and relay URLs from domains
1327 let clone_urls: Vec<String> = domains
1328 .iter()
1329 .map(|d| format!("http://{}/{}/{}.git", d, npub, identifier))
1330 .collect();
1331 let relay_urls: Vec<String> = domains.iter().map(|d| format!("ws://{}", d)).collect();
1332
1333 // Build announcement event (lists ALL domains for relay discovery)
1334 let announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "Repository state")
1335 .tags(vec![
1336 Tag::identifier(identifier),
1337 Tag::custom(TagKind::custom("clone"), clone_urls.clone()),
1338 Tag::custom(TagKind::custom("relays"), relay_urls.clone()),
1339 ])
1340 .sign_with_keys(keys)
1341 .expect("Failed to sign repo announcement");
1342
1343 // Build state event with all domains' clone URLs
1344 let state_event = create_state_event(
1345 keys,
1346 identifier,
1347 &[("main", &commit_hash)],
1348 &[],
1349 &clone_urls.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
1350 &relay_urls.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
1351 )
1352 .expect("Failed to create state event");
1353
1354 // Send announcement and state event to relay (both go to purgatory)
1355 send_to_relay(relay, &announcement)
1356 .await
1357 .expect("Failed to send announcement");
1358 send_to_relay(relay, &state_event)
1359 .await
1360 .expect("Failed to send state event");
1361
1362 // Git push to relay → releases both from purgatory
1363 push_to_relay(git_temp_dir.path(), &relay.domain(), &npub, identifier)
1364 .expect("Failed to push git data to relay");
1365
1366 // Brief wait for push processing
1367 tokio::time::sleep(Duration::from_millis(500)).await;
1368
1369 (announcement, git_temp_dir)
1370}
1371
1087/// Unified sync test helper that automatically determines sync mode. 1372/// Unified sync test helper that automatically determines sync mode.
1088/// 1373///
1089/// This function sets up a complete sync test environment by determining whether 1374/// This function sets up a complete sync test environment by determining whether
@@ -1119,6 +1404,10 @@ async fn send_to_relay(relay: &TestRelay, event: &Event) -> Result<(), String> {
1119/// // Assert comment synced to result.syncing_relay 1404/// // Assert comment synced to result.syncing_relay
1120/// ``` 1405/// ```
1121pub async fn run_sync_test(historic_events: &[Event], live_events: &[Event]) -> SyncTestResult { 1406pub async fn run_sync_test(historic_events: &[Event], live_events: &[Event]) -> SyncTestResult {
1407 use super::purgatory_helpers::{
1408 create_state_event, create_test_repo_with_commit, push_to_relay, CommitVariant,
1409 };
1410
1122 // Validate usage - cannot provide events in both slices 1411 // Validate usage - cannot provide events in both slices
1123 let historic_mode = !historic_events.is_empty(); 1412 let historic_mode = !historic_events.is_empty();
1124 let live_mode = !live_events.is_empty(); 1413 let live_mode = !live_events.is_empty();
@@ -1137,39 +1426,93 @@ pub async fn run_sync_test(historic_events: &[Event], live_events: &[Event]) ->
1137 // 2. Start source relay 1426 // 2. Start source relay
1138 let source = TestRelay::start().await; 1427 let source = TestRelay::start().await;
1139 1428
1140 // 3. Create keys and announcement listing both relays 1429 // 3. Create local git repo with a commit
1430 let git_temp_dir = tempfile::tempdir().expect("Failed to create temp dir for git repo");
1431 let commit_hash = create_test_repo_with_commit(git_temp_dir.path(), CommitVariant::StateTest)
1432 .expect("Failed to create test git repo");
1433
1434 // 4. Create keys and build URLs
1141 let keys = Keys::generate(); 1435 let keys = Keys::generate();
1142 let announcement = 1436 let npub = keys
1143 create_repo_announcement(&keys, &[&source.domain(), &syncing_domain], "test-repo"); 1437 .public_key()
1438 .to_bech32()
1439 .expect("Failed to convert public key to npub");
1440
1441 // Clone URLs: source relay HTTP endpoint is where git data lives
1442 // The syncing relay's purgatory will fetch from source's clone URL
1443 let clone_url_source = format!("http://{}/{}/{}.git", source.domain(), npub, "test-repo");
1444 let clone_url_syncing = format!("http://{}/{}/{}.git", syncing_domain, npub, "test-repo");
1144 1445
1145 // 4. Send announcement + historic events to source BEFORE syncing relay starts 1446 let clone_urls = vec![clone_url_source.clone(), clone_url_syncing.clone()];
1447 let relay_urls = vec![
1448 format!("ws://{}", source.domain()),
1449 format!("ws://{}", syncing_domain),
1450 ];
1451
1452 let announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "Repository state")
1453 .tags(vec![
1454 Tag::identifier("test-repo"),
1455 Tag::custom(TagKind::custom("clone"), clone_urls.clone()),
1456 Tag::custom(TagKind::custom("relays"), relay_urls.clone()),
1457 ])
1458 .sign_with_keys(&keys)
1459 .expect("Failed to sign repo announcement");
1460
1461 // 5. Create state event referencing the commit
1462 let state_event = create_state_event(
1463 &keys,
1464 "test-repo",
1465 &[("main", &commit_hash)],
1466 &[],
1467 &clone_urls.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
1468 &relay_urls.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
1469 )
1470 .expect("Failed to create state event");
1471
1472 // 6. Send announcement + state event to source (both go to purgatory)
1146 send_to_relay(&source, &announcement) 1473 send_to_relay(&source, &announcement)
1147 .await 1474 .await
1148 .expect("Failed to send announcement"); 1475 .expect("Failed to send announcement");
1476 send_to_relay(&source, &state_event)
1477 .await
1478 .expect("Failed to send state event");
1479
1480 // 7. Git push to source relay → releases both announcement and state event from purgatory
1481 push_to_relay(git_temp_dir.path(), &source.domain(), &npub, "test-repo")
1482 .expect("Failed to push git data to source relay");
1483
1484 // 8. Wait for source relay to process the push and release events from purgatory
1485 tokio::time::sleep(Duration::from_secs(2)).await;
1486
1487 // 9. Send historic events to source BEFORE syncing relay starts
1149 for event in historic_events { 1488 for event in historic_events {
1150 send_to_relay(&source, event) 1489 send_to_relay(&source, event)
1151 .await 1490 .await
1152 .expect("Failed to send historic event"); 1491 .expect("Failed to send historic event");
1153 } 1492 }
1154 1493
1155 // 5. Start syncing relay (connects to source) 1494 // 10. Start syncing relay (connects to source)
1156 let syncing = 1495 let syncing =
1157 TestRelay::start_on_port_with_options(syncing_port, Some(source.url().into()), false).await; 1496 TestRelay::start_on_port_with_options(syncing_port, Some(source.url().into()), false).await;
1158 1497
1159 // 6. Wait for sync connection to establish 1498 // 11. Wait for sync connection to establish
1160 let _ = wait_for_sync_connection(syncing.url(), 1, Duration::from_secs(5)).await; 1499 let _ = wait_for_sync_connection(syncing.url(), 1, Duration::from_secs(5)).await;
1161 1500
1162 // 7. Send live events AFTER connection established 1501 // 12. Send live events AFTER connection established
1163 for event in live_events { 1502 for event in live_events {
1164 send_to_relay(&source, event) 1503 send_to_relay(&source, event)
1165 .await 1504 .await
1166 .expect("Failed to send live event"); 1505 .expect("Failed to send live event");
1167 } 1506 }
1168 1507
1169 // 8. Allow sync to complete 1508 // 13. Allow sync + purgatory promotion to complete on the syncing relay.
1170 tokio::time::sleep(Duration::from_millis(100)).await; 1509 // The syncing relay receives the announcement (goes to purgatory) and state event.
1510 // The purgatory sync loop (1s interval) fetches git data from source's clone URL
1511 // (http://source-domain/npub/test-repo.git) and releases the announcement.
1512 // We wait up to 8s to allow time for this.
1513 tokio::time::sleep(Duration::from_secs(8)).await;
1171 1514
1172 // 9. Compute repo coordinate before moving keys 1515 // 14. Compute repo coordinate before moving keys
1173 let coordinate = repo_coord(&keys, "test-repo"); 1516 let coordinate = repo_coord(&keys, "test-repo");
1174 1517
1175 SyncTestResult { 1518 SyncTestResult {
@@ -1177,6 +1520,8 @@ pub async fn run_sync_test(historic_events: &[Event], live_events: &[Event]) ->
1177 syncing_relay: syncing, 1520 syncing_relay: syncing,
1178 maintainer_keys: keys, 1521 maintainer_keys: keys,
1179 repo_coord: coordinate, 1522 repo_coord: coordinate,
1523 _git_server: None,
1524 _git_temp_dir: Some(git_temp_dir),
1180 } 1525 }
1181} 1526}
1182 1527
diff --git a/tests/nip77_negentropy.rs b/tests/nip77_negentropy.rs
index fccfe67..29e62d8 100644
--- a/tests/nip77_negentropy.rs
+++ b/tests/nip77_negentropy.rs
@@ -35,56 +35,67 @@ use common::{sync_helpers::*, TestRelay};
35/// 3. Create a fresh client with empty local database 35/// 3. Create a fresh client with empty local database
36/// 4. Call client.sync() to perform negentropy reconciliation 36/// 4. Call client.sync() to perform negentropy reconciliation
37/// 5. Verify reconciliation found the events on the relay 37/// 5. Verify reconciliation found the events on the relay
38///
39/// Uses kind 10317 (GitUserGraspList) events which are unconditionally accepted
40/// by the relay without requiring a promoted repository. This avoids the
41/// announcements-purgatory system which holds kind 30617 events until git data
42/// arrives, meaning announcement events are not stored in the DB and would not
43/// appear in negentropy sync results.
38#[tokio::test] 44#[tokio::test]
39async fn test_nip77_negentropy_sync_finds_events() { 45async fn test_nip77_negentropy_sync_finds_events() {
40 // 1. Start relay 46 // 1. Start relay
41 let relay = TestRelay::start().await; 47 let relay = TestRelay::start().await;
42 println!("Relay started at {}", relay.url()); 48 println!("Relay started at {}", relay.url());
43 49
44 // 2. Create keys and publish events 50 // 2. Create two distinct keypairs - each publishes a kind 10317 event.
45 let keys = Keys::generate(); 51 // Kind 10317 (GitUserGraspList) is unconditionally accepted and stored in
46 52 // the relay DB, unlike kind 30617 announcements which go to purgatory.
47 // Create a repository announcement that will be accepted by the relay 53 let keys1 = Keys::generate();
48 let announcement = create_repo_announcement(&keys, &[&relay.domain()], "test-repo-nip77"); 54 let keys2 = Keys::generate();
49 let event1_id = announcement.id; 55
56 // Build kind 10317 events (replaceable per pubkey, so two keys = two stored events)
57 let event1 = EventBuilder::new(Kind::GitUserGraspList, "")
58 .tags(vec![Tag::identifier("grasp-list-nip77-a")])
59 .sign_with_keys(&keys1)
60 .expect("Failed to sign event 1");
61 let event1_id = event1.id;
50 println!( 62 println!(
51 "Created event 1: {} (kind {})", 63 "Created event 1: {} (kind {})",
52 event1_id, 64 event1_id,
53 announcement.kind.as_u16() 65 event1.kind.as_u16()
54 ); 66 );
55 67
56 // Create a second event (issue referencing the repo) 68 let event2 = EventBuilder::new(Kind::GitUserGraspList, "")
57 let repo_coord = format!( 69 .tags(vec![Tag::identifier("grasp-list-nip77-b")])
58 "{}:{}:{}", 70 .sign_with_keys(&keys2)
59 Kind::GitRepoAnnouncement.as_u16(), 71 .expect("Failed to sign event 2");
60 keys.public_key().to_hex(), 72 let event2_id = event2.id;
61 "test-repo-nip77"
62 );
63 let issue = build_layer2_issue_event(&keys, &repo_coord, "Test issue for NIP-77")
64 .expect("Failed to build issue event");
65 let event2_id = issue.id;
66 println!( 73 println!(
67 "Created event 2: {} (kind {})", 74 "Created event 2: {} (kind {})",
68 event2_id, 75 event2_id,
69 issue.kind.as_u16() 76 event2.kind.as_u16()
70 ); 77 );
71 78
72 // 3. Send events to relay using TestClient 79 // 3. Send events to relay using TestClient
73 let publish_client = TestClient::new(relay.url(), keys.clone()) 80 let publish_client1 = TestClient::new(relay.url(), keys1.clone())
74 .await 81 .await
75 .expect("Failed to connect to relay"); 82 .expect("Failed to connect to relay");
83 publish_client1
84 .send_event(&event1)
85 .await
86 .expect("Failed to send event 1");
87 publish_client1.disconnect().await;
76 88
77 publish_client 89 let publish_client2 = TestClient::new(relay.url(), keys2.clone())
78 .send_event(&announcement)
79 .await 90 .await
80 .expect("Failed to send announcement"); 91 .expect("Failed to connect to relay");
81 publish_client 92 publish_client2
82 .send_event(&issue) 93 .send_event(&event2)
83 .await 94 .await
84 .expect("Failed to send issue"); 95 .expect("Failed to send event 2");
85 println!("Events published to relay"); 96 publish_client2.disconnect().await;
86 97
87 publish_client.disconnect().await; 98 println!("Events published to relay");
88 99
89 // 4. Wait a moment for events to be stored 100 // 4. Wait a moment for events to be stored
90 tokio::time::sleep(Duration::from_millis(200)).await; 101 tokio::time::sleep(Duration::from_millis(200)).await;
@@ -104,8 +115,8 @@ async fn test_nip77_negentropy_sync_finds_events() {
104 115
105 // 6. Perform negentropy sync with filter matching our events 116 // 6. Perform negentropy sync with filter matching our events
106 let filter = Filter::new() 117 let filter = Filter::new()
107 .author(keys.public_key()) 118 .authors(vec![keys1.public_key(), keys2.public_key()])
108 .kinds(vec![Kind::GitRepoAnnouncement, Kind::GitIssue]); 119 .kind(Kind::GitUserGraspList);
109 120
110 println!("Starting negentropy sync with filter: {:?}", filter); 121 println!("Starting negentropy sync with filter: {:?}", filter);
111 122
diff --git a/tests/purgatory.rs b/tests/purgatory.rs
new file mode 100644
index 0000000..73f85ca
--- /dev/null
+++ b/tests/purgatory.rs
@@ -0,0 +1,89 @@
1//! Purgatory Integration Tests
2//!
3//! Tests ngit-grasp relay's implementation of GRASP-01 purgatory behavior.
4//! Uses grasp-audit library to avoid code duplication.
5//!
6//! # Test Strategy
7//!
8//! - Each test runs in complete isolation with its own fresh relay instance
9//! - Uses macro to eliminate boilerplate while maintaining test isolation
10//! - Calls individual test methods from grasp-audit for minimal duplication
11//! - Automatic cleanup via TestRelay fixture (removes container and temp dirs)
12//!
13//! # Running Tests
14//!
15//! ```bash
16//! # Run all purgatory tests
17//! cargo test --test purgatory
18//!
19//! # Run specific test
20//! cargo test --test purgatory test_state_event_not_served_before_git_data
21//!
22//! # With output
23//! cargo test --test purgatory -- --nocapture
24//! ```
25
26mod common;
27
28use common::TestRelay;
29use grasp_audit::specs::grasp01::PurgatoryTests;
30use grasp_audit::{AuditClient, AuditConfig};
31
32/// Macro to generate isolated integration tests for purgatory
33///
34/// Each test runs with its own fresh relay instance to ensure complete isolation.
35/// This eliminates issues with leftover repositories and ensures clean state.
36macro_rules! isolated_purgatory_test {
37 ($test_name:ident) => {
38 #[tokio::test]
39 async fn $test_name() {
40 let relay = TestRelay::start().await;
41 let config = AuditConfig::isolated();
42 let client = AuditClient::new(relay.url(), config)
43 .await
44 .expect("Failed to create audit client");
45
46 let result = PurgatoryTests::$test_name(&client).await;
47
48 relay.stop().await;
49
50 assert!(
51 result.passed,
52 "{} failed: {}",
53 stringify!($test_name),
54 result.error.as_deref().unwrap_or("unknown error")
55 );
56 }
57 };
58}
59
60// ============================================================
61// Announcement Purgatory Tests
62// ============================================================
63
64isolated_purgatory_test!(test_announcement_not_served_before_git_data);
65isolated_purgatory_test!(test_announcement_served_after_git_push);
66isolated_purgatory_test!(test_bare_repo_exists_for_purgatory_announcement);
67isolated_purgatory_test!(test_state_event_accepted_for_purgatory_announcement);
68
69// ============================================================
70// Deletion Event Tests (NIP-09)
71// ============================================================
72
73isolated_purgatory_test!(test_deletion_by_event_id_removes_purgatory_state_event);
74isolated_purgatory_test!(test_deletion_by_coordinate_removes_purgatory_state_event);
75
76// ============================================================
77// State Event Purgatory Tests (already implemented)
78// ============================================================
79
80isolated_purgatory_test!(test_state_event_not_served_before_git_data);
81isolated_purgatory_test!(test_state_event_served_after_git_push);
82
83// ============================================================
84// PR Purgatory Tests
85// ============================================================
86
87isolated_purgatory_test!(test_pr_event_accepted_into_purgatory_and_isnt_served);
88isolated_purgatory_test!(test_pr_event_in_purgatory_git_push_accepted);
89isolated_purgatory_test!(test_pr_event_served_after_git_push);
diff --git a/tests/purgatory_persistence.rs b/tests/purgatory_persistence.rs
index 4dc5e94..655b0d9 100644
--- a/tests/purgatory_persistence.rs
+++ b/tests/purgatory_persistence.rs
@@ -31,9 +31,11 @@
31 31
32mod common; 32mod common;
33 33
34use common::purgatory_helpers::create_announcement_event;
34use ngit_grasp::purgatory::Purgatory; 35use ngit_grasp::purgatory::Purgatory;
35use ngit_grasp::sync::rejected_index::{EventType, RejectedEventsIndex, RejectionReason}; 36use ngit_grasp::sync::rejected_index::{EventType, RejectedEventsIndex, RejectionReason};
36use nostr_sdk::prelude::*; 37use nostr_sdk::prelude::*;
38use std::collections::HashSet;
37use std::time::Duration; 39use std::time::Duration;
38 40
39/// Helper to create a test event 41/// Helper to create a test event
@@ -120,11 +122,31 @@ async fn test_full_purgatory_save_restore_cycle() {
120 // Add a PR placeholder (git-data-first scenario) 122 // Add a PR placeholder (git-data-first scenario)
121 purgatory.add_pr_placeholder("placeholder-id".to_string(), "commit-xyz".to_string()); 123 purgatory.add_pr_placeholder("placeholder-id".to_string(), "commit-xyz".to_string());
122 124
123 // Note: We can't directly test expired events without accessing private fields, 125 // Add an announcement to purgatory (requires a real directory for the repo path)
124 // so we'll focus on testing state and PR events persistence 126 let repo_dir = temp_dir.path().join("repo.git");
127 std::fs::create_dir_all(&repo_dir).unwrap();
128 let ann_keys = Keys::generate();
129 let ann_event = create_announcement_event(
130 &ann_keys,
131 "my-repo",
132 &["http://example.com/my-repo.git"],
133 &["wss://relay.example.com"],
134 )
135 .unwrap();
136 let ann_event_id = ann_event.id;
137 let mut ann_relays = HashSet::new();
138 ann_relays.insert("wss://relay.example.com".to_string());
139 purgatory.add_announcement(
140 ann_event,
141 "my-repo".to_string(),
142 ann_keys.public_key(),
143 repo_dir.clone(),
144 ann_relays,
145 );
125 146
126 // Verify initial counts 147 // Verify initial counts
127 let (state_count, pr_count) = purgatory.count(); 148 let (announcement_count, state_count, pr_count) = purgatory.count();
149 assert_eq!(announcement_count, 1, "Should have 1 announcement");
128 assert_eq!(state_count, 2, "Should have 2 state events"); 150 assert_eq!(state_count, 2, "Should have 2 state events");
129 assert_eq!( 151 assert_eq!(
130 pr_count, 3, 152 pr_count, 3,
@@ -146,13 +168,23 @@ async fn test_full_purgatory_save_restore_cycle() {
146 ); 168 );
147 169
148 // Verify all data was restored 170 // Verify all data was restored
149 let (state_count2, pr_count2) = purgatory2.count(); 171 let (announcement_count2, state_count2, pr_count2) = purgatory2.count();
172 assert_eq!(announcement_count2, 1, "Should have 1 announcement after restore");
150 assert_eq!(state_count2, 2, "Should have 2 state events after restore"); 173 assert_eq!(state_count2, 2, "Should have 2 state events after restore");
151 assert_eq!( 174 assert_eq!(
152 pr_count2, 3, 175 pr_count2, 3,
153 "Should have 3 PR events after restore (2 events + 1 placeholder)" 176 "Should have 3 PR events after restore (2 events + 1 placeholder)"
154 ); 177 );
155 178
179 // Verify announcement was restored correctly
180 let restored_ann = purgatory2
181 .find_announcement(&ann_keys.public_key(), "my-repo")
182 .expect("Announcement should be restored");
183 assert_eq!(restored_ann.event.id, ann_event_id);
184 assert_eq!(restored_ann.identifier, "my-repo");
185 assert_eq!(restored_ann.repo_path, repo_dir);
186 assert!(!restored_ann.soft_expired);
187
156 // Verify specific state events 188 // Verify specific state events
157 let repo1_states = purgatory2.find_state("repo1"); 189 let repo1_states = purgatory2.find_state("repo1");
158 assert_eq!(repo1_states.len(), 1); 190 assert_eq!(repo1_states.len(), 1);
@@ -284,7 +316,7 @@ async fn test_purgatory_downtime_adjustment() {
284 purgatory2.restore_from_disk(&state_path).unwrap(); 316 purgatory2.restore_from_disk(&state_path).unwrap();
285 317
286 // Verify event is still there (downtime was accounted for) 318 // Verify event is still there (downtime was accounted for)
287 let (state_count, _) = purgatory2.count(); 319 let (_, state_count, _) = purgatory2.count();
288 assert_eq!(state_count, 1); 320 assert_eq!(state_count, 1);
289 321
290 let repo1_states = purgatory2.find_state("repo1"); 322 let repo1_states = purgatory2.find_state("repo1");
@@ -410,7 +442,7 @@ async fn test_purgatory_restore_missing_file() {
410 assert!(result.is_err(), "Should error on missing file"); 442 assert!(result.is_err(), "Should error on missing file");
411 443
412 // Purgatory should still be usable (empty state) 444 // Purgatory should still be usable (empty state)
413 let (state_count, pr_count) = purgatory.count(); 445 let (_, state_count, pr_count) = purgatory.count();
414 assert_eq!(state_count, 0); 446 assert_eq!(state_count, 0);
415 assert_eq!(pr_count, 0); 447 assert_eq!(pr_count, 0);
416 448
@@ -419,7 +451,7 @@ async fn test_purgatory_restore_missing_file() {
419 let event = create_test_event(&keys, "test").await; 451 let event = create_test_event(&keys, "test").await;
420 purgatory.add_state(event, "repo1".to_string(), keys.public_key(), false); 452 purgatory.add_state(event, "repo1".to_string(), keys.public_key(), false);
421 453
422 let (state_count, _) = purgatory.count(); 454 let (_, state_count, _) = purgatory.count();
423 assert_eq!(state_count, 1); 455 assert_eq!(state_count, 1);
424} 456}
425 457
@@ -470,7 +502,7 @@ async fn test_purgatory_restore_corrupted_file() {
470 assert!(result.is_err(), "Should error on corrupted file"); 502 assert!(result.is_err(), "Should error on corrupted file");
471 503
472 // Purgatory should still be usable 504 // Purgatory should still be usable
473 let (state_count, pr_count) = purgatory.count(); 505 let (_, state_count, pr_count) = purgatory.count();
474 assert_eq!(state_count, 0); 506 assert_eq!(state_count, 0);
475 assert_eq!(pr_count, 0); 507 assert_eq!(pr_count, 0);
476} 508}
@@ -513,7 +545,7 @@ async fn test_empty_purgatory_save_restore() {
513 purgatory2.restore_from_disk(&state_path).unwrap(); 545 purgatory2.restore_from_disk(&state_path).unwrap();
514 546
515 // Verify empty state 547 // Verify empty state
516 let (state_count, pr_count) = purgatory2.count(); 548 let (_, state_count, pr_count) = purgatory2.count();
517 assert_eq!(state_count, 0); 549 assert_eq!(state_count, 0);
518 assert_eq!(pr_count, 0); 550 assert_eq!(pr_count, 0);
519 assert_eq!(purgatory2.expired_count(), 0); 551 assert_eq!(purgatory2.expired_count(), 0);
@@ -620,7 +652,7 @@ async fn test_purgatory_continues_working_after_restore() {
620 ); 652 );
621 653
622 // Verify both old and new events work 654 // Verify both old and new events work
623 let (state_count, _) = purgatory2.count(); 655 let (_, state_count, _) = purgatory2.count();
624 assert_eq!(state_count, 2); 656 assert_eq!(state_count, 2);
625 657
626 let repo1_states = purgatory2.find_state("repo1"); 658 let repo1_states = purgatory2.find_state("repo1");
@@ -632,7 +664,7 @@ async fn test_purgatory_continues_working_after_restore() {
632 assert_eq!(repo2_states[0].event.id, event2.id); 664 assert_eq!(repo2_states[0].event.id, event2.id);
633 665
634 // Verify cleanup still works 666 // Verify cleanup still works
635 let (state_removed, pr_removed) = purgatory2.cleanup(); 667 let (_, state_removed, pr_removed) = purgatory2.cleanup();
636 // Nothing should be expired yet 668 // Nothing should be expired yet
637 assert_eq!(state_removed, 0); 669 assert_eq!(state_removed, 0);
638 assert_eq!(pr_removed, 0); 670 assert_eq!(pr_removed, 0);
@@ -713,15 +745,15 @@ async fn test_purgatory_entries_expired_during_downtime() {
713 purgatory2.restore_from_disk(&state_path).unwrap(); 745 purgatory2.restore_from_disk(&state_path).unwrap();
714 746
715 // Event should be restored 747 // Event should be restored
716 let (state_count, _) = purgatory2.count(); 748 let (_, state_count, _) = purgatory2.count();
717 assert_eq!(state_count, 1); 749 assert_eq!(state_count, 1);
718 750
719 // Cleanup should work (even if nothing is expired yet) 751 // Cleanup should work (even if nothing is expired yet)
720 let (state_removed, _) = purgatory2.cleanup(); 752 let (_, state_removed, _) = purgatory2.cleanup();
721 // Nothing expired yet since we didn't wait 30 minutes 753 // Nothing expired yet since we didn't wait 30 minutes
722 assert_eq!(state_removed, 0); 754 assert_eq!(state_removed, 0);
723 755
724 let (state_count, _) = purgatory2.count(); 756 let (_, state_count, _) = purgatory2.count();
725 assert_eq!(state_count, 1); 757 assert_eq!(state_count, 1);
726} 758}
727 759
@@ -775,3 +807,100 @@ async fn test_rejected_cache_entries_expired_during_downtime() {
775 assert_eq!(index2.hot_cache_len(), 0); 807 assert_eq!(index2.hot_cache_len(), 0);
776 assert_eq!(index2.cold_index_len(), 1); 808 assert_eq!(index2.cold_index_len(), 1);
777} 809}
810
811/// Test 18: Announcement events are saved and restored across restarts
812#[tokio::test]
813async fn test_announcement_save_restore_cycle() {
814 let temp_dir = tempfile::tempdir().unwrap();
815 let git_data_path = temp_dir.path().join("git");
816 let state_path = temp_dir.path().join("purgatory.json");
817
818 // Create a real bare repo directory (restore skips entries whose path is missing)
819 let repo_dir = temp_dir.path().join("owner.git");
820 std::fs::create_dir_all(&repo_dir).unwrap();
821
822 let purgatory = Purgatory::new(&git_data_path);
823 let keys = Keys::generate();
824
825 let ann_event = create_announcement_event(
826 &keys,
827 "my-repo",
828 &["http://example.com/my-repo.git"],
829 &["wss://relay.example.com"],
830 )
831 .unwrap();
832 let ann_event_id = ann_event.id;
833
834 let mut relays = HashSet::new();
835 relays.insert("wss://relay.example.com".to_string());
836
837 purgatory.add_announcement(
838 ann_event,
839 "my-repo".to_string(),
840 keys.public_key(),
841 repo_dir.clone(),
842 relays.clone(),
843 );
844
845 let (ann_count, _, _) = purgatory.count();
846 assert_eq!(ann_count, 1);
847
848 // Save to disk
849 purgatory.save_to_disk(&state_path).unwrap();
850 assert!(state_path.exists());
851
852 // Restore into a fresh purgatory
853 let purgatory2 = Purgatory::new(&git_data_path);
854 purgatory2.restore_from_disk(&state_path).unwrap();
855
856 assert!(!state_path.exists(), "State file should be deleted after restore");
857
858 let (ann_count2, _, _) = purgatory2.count();
859 assert_eq!(ann_count2, 1, "Announcement should be restored");
860
861 let restored = purgatory2
862 .find_announcement(&keys.public_key(), "my-repo")
863 .expect("Announcement should be findable after restore");
864
865 assert_eq!(restored.event.id, ann_event_id);
866 assert_eq!(restored.identifier, "my-repo");
867 assert_eq!(restored.owner, keys.public_key());
868 assert_eq!(restored.repo_path, repo_dir);
869 assert_eq!(restored.relays, relays);
870 assert!(!restored.soft_expired);
871}
872
873/// Test 19: Announcement with missing repo path is skipped on restore
874#[tokio::test]
875async fn test_announcement_missing_repo_skipped_on_restore() {
876 let temp_dir = tempfile::tempdir().unwrap();
877 let git_data_path = temp_dir.path().join("git");
878 let state_path = temp_dir.path().join("purgatory.json");
879
880 // Point to a path that does NOT exist on disk
881 let missing_repo = temp_dir.path().join("nonexistent.git");
882
883 let purgatory = Purgatory::new(&git_data_path);
884 let keys = Keys::generate();
885
886 let ann_event = create_announcement_event(&keys, "my-repo", &[], &[]).unwrap();
887
888 purgatory.add_announcement(
889 ann_event,
890 "my-repo".to_string(),
891 keys.public_key(),
892 missing_repo,
893 HashSet::new(),
894 );
895
896 purgatory.save_to_disk(&state_path).unwrap();
897
898 let purgatory2 = Purgatory::new(&git_data_path);
899 purgatory2.restore_from_disk(&state_path).unwrap();
900
901 let (ann_count, _, _) = purgatory2.count();
902 assert_eq!(
903 ann_count, 0,
904 "Announcement with missing repo path must be skipped"
905 );
906}
diff --git a/tests/purgatory_sync.rs b/tests/purgatory_sync.rs
index 72f3d81..eefd6bc 100644
--- a/tests/purgatory_sync.rs
+++ b/tests/purgatory_sync.rs
@@ -282,15 +282,20 @@ async fn test_state_event_syncs_from_remote() {
282/// Test that a PR event entering purgatory triggers remote commit fetch 282/// Test that a PR event entering purgatory triggers remote commit fetch
283/// and is released once the commit is available. 283/// and is released once the commit is available.
284/// 284///
285/// Scenario: 285/// Flow on source relay:
286/// 1. Start source relay with repository announcement 286/// 1. Send announcement → purgatory (StateOnly - no git data yet)
287/// 2. Create PR event (goes to purgatory - no git data yet) 287/// 2. Send state event → purgatory (refs point to non-existent commits)
288/// 3. Push commit to refs/nostr/<event-id> (authorized by PR event in purgatory) 288/// 3. Push git data → promotes announcement to Full + releases state event
289/// 4. PR event gets released from purgatory on source relay 289/// 4. Send PR event → purgatory (announcement now Full, so PR events accepted)
290/// 5. Start syncing relay 290/// 5. Push PR commit → releases PR event
291/// 6. Syncing relay syncs PR event (goes to purgatory - no local git data) 291///
292/// 7. Syncing relay fetches commit from source's clone URL 292/// Flow on syncing relay:
293/// 8. Verify PR event is released and refs/nostr/<event-id> created on syncing relay 293/// 6. Start syncing relay
294/// 7. Syncs announcement → purgatory (StateOnly)
295/// 8. Syncs state event → purgatory
296/// 9. Fetches git data → promotes announcement (Full) + releases state event
297/// 10. Syncs PR event → purgatory (announcement now Full)
298/// 11. Fetches PR commit → releases PR event
294#[tokio::test] 299#[tokio::test]
295async fn test_pr_event_syncs_from_remote() { 300async fn test_pr_event_syncs_from_remote() {
296 // 1. Start source relay 301 // 1. Start source relay
@@ -313,8 +318,7 @@ async fn test_pr_event_syncs_from_remote() {
313 .to_bech32() 318 .to_bech32()
314 .expect("Failed to get npub"); 319 .expect("Failed to get npub");
315 320
316 // 3. Create and send announcement listing BOTH relays 321 // 3. Create announcement listing BOTH relays
317 // This ensures the syncing relay will accept the PR event when it syncs
318 let announcement = create_repo_announcement( 322 let announcement = create_repo_announcement(
319 &owner_keys, 323 &owner_keys,
320 &[&source_relay.domain(), &syncing_domain], 324 &[&source_relay.domain(), &syncing_domain],
@@ -331,7 +335,7 @@ async fn test_pr_event_syncs_from_remote() {
331 // Wait for connection 335 // Wait for connection
332 tokio::time::sleep(Duration::from_millis(500)).await; 336 tokio::time::sleep(Duration::from_millis(500)).await;
333 337
334 // Send announcement to source relay (creates bare repo) 338 // Step 1: Send announcement to source relay → purgatory (StateOnly)
335 source_client 339 source_client
336 .send_event(&announcement) 340 .send_event(&announcement)
337 .await 341 .await
@@ -339,8 +343,52 @@ async fn test_pr_event_syncs_from_remote() {
339 343
340 tokio::time::sleep(Duration::from_millis(200)).await; 344 tokio::time::sleep(Duration::from_millis(200)).await;
341 345
342 // 4. Create and send PR event BEFORE pushing 346 // Step 2: Create and send state event → purgatory (no git data yet)
343 // The PR event goes to purgatory on source relay, which authorizes the push 347 let clone_urls = [
348 format!(
349 "http://{}/{}/{}.git",
350 source_relay.domain(),
351 npub,
352 identifier
353 ),
354 format!("http://{}/{}/{}.git", syncing_domain, npub, identifier),
355 ];
356 let relay_urls = [
357 source_relay.url().to_string(),
358 format!("ws://{}", syncing_domain),
359 ];
360
361 let state_event = create_state_event(
362 &owner_keys,
363 identifier,
364 &[("main", &commit_hash)],
365 &[],
366 &[&clone_urls[0], &clone_urls[1]],
367 &[&relay_urls[0], &relay_urls[1]],
368 )
369 .expect("Failed to create state event");
370
371 let state_event_id = state_event.id;
372
373 source_client
374 .send_event(&state_event)
375 .await
376 .expect("Failed to send state event to source");
377
378 tokio::time::sleep(Duration::from_millis(200)).await;
379
380 // Step 3: Push git data to source relay
381 // This promotes the announcement from StateOnly to Full AND releases state event
382 push_to_relay(temp_dir.path(), &source_relay.domain(), &npub, identifier)
383 .expect("Push to source should succeed");
384
385 // Wait for state event to be released from purgatory on source relay
386 wait_for_event_served(source_relay.url(), &state_event_id, Duration::from_secs(5))
387 .await
388 .expect("State event should be served on source relay after push");
389
390 // Step 4: Create and send PR event → purgatory
391 // NOW the announcement is promoted (Full), so PR events are accepted
344 let repo_coord = build_repo_coord(&owner_keys, identifier); 392 let repo_coord = build_repo_coord(&owner_keys, identifier);
345 393
346 let pr_event = create_pr_event( 394 let pr_event = create_pr_event(
@@ -367,11 +415,10 @@ async fn test_pr_event_syncs_from_remote() {
367 .await 415 .await
368 .expect("Failed to send PR event to source"); 416 .expect("Failed to send PR event to source");
369 417
370 // Small delay to ensure PR event is processed into purgatory
371 tokio::time::sleep(Duration::from_millis(200)).await; 418 tokio::time::sleep(Duration::from_millis(200)).await;
372 419
373 // 5. Push commit to refs/nostr/<event-id> on source relay 420 // Step 5: Push PR commit to refs/nostr/<event-id> on source relay
374 // The PR event in purgatory authorizes this push 421 // This releases the PR event from purgatory
375 let ref_name = format!("refs/nostr/{}", pr_event_id.to_hex()); 422 let ref_name = format!("refs/nostr/{}", pr_event_id.to_hex());
376 push_ref_to_relay( 423 push_ref_to_relay(
377 temp_dir.path(), 424 temp_dir.path(),
@@ -383,12 +430,12 @@ async fn test_pr_event_syncs_from_remote() {
383 ) 430 )
384 .expect("Push to refs/nostr/<event-id> should succeed"); 431 .expect("Push to refs/nostr/<event-id> should succeed");
385 432
386 // After push, PR event should be released from purgatory on source relay 433 // Wait for PR event to be released from purgatory on source relay
387 wait_for_event_served(source_relay.url(), &pr_event_id, Duration::from_secs(5)) 434 wait_for_event_served(source_relay.url(), &pr_event_id, Duration::from_secs(5))
388 .await 435 .await
389 .expect("PR event should be served on source relay after push"); 436 .expect("PR event should be served on source relay after push");
390 437
391 // 6. Start syncing relay (syncs from source) 438 // Step 6: Start syncing relay (syncs from source)
392 let syncing_relay = TestRelay::start_on_port_with_options( 439 let syncing_relay = TestRelay::start_on_port_with_options(
393 syncing_port, 440 syncing_port,
394 Some(source_relay.url().to_string()), 441 Some(source_relay.url().to_string()),
@@ -401,14 +448,13 @@ async fn test_pr_event_syncs_from_remote() {
401 .await 448 .await
402 .expect("Sync connection should establish"); 449 .expect("Sync connection should establish");
403 450
404 // 7. Wait for PR event to be released on syncing relay 451 // Steps 7-11: Syncing relay syncs events
405 // The sync should: 452 // The sync should:
406 // a) Fetch the announcement and PR event from source relay 453 // a) Sync announcement → purgatory (StateOnly)
407 // b) Accept announcement (creates bare repo structure) 454 // b) Sync state event → purgatory
408 // c) Put PR event in purgatory (commit missing on syncing relay) 455 // c) Fetch git data → promotes announcement (Full) + releases state event
409 // d) Fetch commit from source relay's clone URL 456 // d) Sync PR event → purgatory (announcement now Full)
410 // e) Release the PR event from purgatory 457 // e) Fetch PR commit → releases PR event
411 // f) Create refs/nostr/<event-id> pointing to the commit
412 let found = wait_for_event_served( 458 let found = wait_for_event_served(
413 syncing_relay.url(), 459 syncing_relay.url(),
414 &pr_event_id, 460 &pr_event_id,
@@ -422,7 +468,7 @@ async fn test_pr_event_syncs_from_remote() {
422 found.err() 468 found.err()
423 ); 469 );
424 470
425 // 8. Verify refs/nostr/<event-id> was created on syncing relay 471 // Verify refs/nostr/<event-id> was created on syncing relay
426 let ref_correct = 472 let ref_correct =
427 check_ref_at_commit(&syncing_domain, &npub, identifier, &ref_name, &commit_hash) 473 check_ref_at_commit(&syncing_domain, &npub, identifier, &ref_name, &commit_hash)
428 .await 474 .await
@@ -443,14 +489,20 @@ async fn test_pr_event_syncs_from_remote() {
443/// Test that concurrent state and PR events for the same repository 489/// Test that concurrent state and PR events for the same repository
444/// both sync correctly. 490/// both sync correctly.
445/// 491///
446/// Scenario: 492/// Flow on source relay:
447/// 1. Start source relay with repo containing two commits (main branch + PR commit) 493/// 1. Send announcement → purgatory (StateOnly - no git data yet)
448/// 2. Create and push both commits to source relay 494/// 2. Send state event → purgatory (refs point to non-existent commits)
449/// 3. Send both state event and PR event to source relay 495/// 3. Push git data → promotes announcement to Full + releases state event
450/// 4. Start syncing relay 496/// 4. THEN send PR event → purgatory (announcement now Full, so PR events accepted)
451/// 5. Wait for sync to fetch git data and release both events 497/// 5. Push PR commit → releases PR event
452/// 6. Verify both state event and PR event are served 498///
453/// 7. Verify refs are correct for both (main branch and refs/nostr/<event-id>) 499/// Flow on syncing relay:
500/// 6. Start syncing relay
501/// 7. Syncs announcement → purgatory (StateOnly)
502/// 8. Syncs state event → purgatory
503/// 9. Fetches git data → promotes announcement (Full) + releases state event
504/// 10. Syncs PR event → purgatory (announcement now Full)
505/// 11. Fetches PR commit → releases PR event
454#[tokio::test] 506#[tokio::test]
455async fn test_concurrent_state_and_pr_sync() { 507async fn test_concurrent_state_and_pr_sync() {
456 // 1. Start source relay 508 // 1. Start source relay
@@ -464,15 +516,13 @@ async fn test_concurrent_state_and_pr_sync() {
464 let syncing_domain = format!("127.0.0.1:{}", syncing_port); 516 let syncing_domain = format!("127.0.0.1:{}", syncing_port);
465 517
466 // 2. Create test repository with two commits 518 // 2. Create test repository with two commits
467 // First commit establishes the repo, second commit is used for both state and PR events 519 // First commit establishes the repo (for state event), second commit is for PR
468 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); 520 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
469 let _first_commit = create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest) 521 let _state_commit = create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest)
470 .expect("Failed to create test repo"); 522 .expect("Failed to create test repo");
471 523
472 // Add second commit - this becomes HEAD of main and is referenced by both events 524 // Add second commit - this is used for the PR event
473 // In a real scenario, the state event would reference the current branch state, 525 let pr_commit =
474 // and the PR would propose changes (which happen to be the same commit here for simplicity)
475 let head_commit =
476 add_commit_to_repo(temp_dir.path(), CommitVariant::PrTest).expect("Failed to add commit"); 526 add_commit_to_repo(temp_dir.path(), CommitVariant::PrTest).expect("Failed to add commit");
477 527
478 let npub = owner_keys 528 let npub = owner_keys
@@ -480,7 +530,7 @@ async fn test_concurrent_state_and_pr_sync() {
480 .to_bech32() 530 .to_bech32()
481 .expect("Failed to get npub"); 531 .expect("Failed to get npub");
482 532
483 // 3. Create and send announcement listing BOTH relays 533 // 3. Create announcement listing BOTH relays
484 let announcement = create_repo_announcement( 534 let announcement = create_repo_announcement(
485 &owner_keys, 535 &owner_keys,
486 &[&source_relay.domain(), &syncing_domain], 536 &[&source_relay.domain(), &syncing_domain],
@@ -497,7 +547,7 @@ async fn test_concurrent_state_and_pr_sync() {
497 // Wait for connection 547 // Wait for connection
498 tokio::time::sleep(Duration::from_millis(500)).await; 548 tokio::time::sleep(Duration::from_millis(500)).await;
499 549
500 // Send announcement to source relay (creates bare repo) 550 // Step 1: Send announcement to source relay → purgatory (StateOnly)
501 source_client 551 source_client
502 .send_event(&announcement) 552 .send_event(&announcement)
503 .await 553 .await
@@ -505,8 +555,7 @@ async fn test_concurrent_state_and_pr_sync() {
505 555
506 tokio::time::sleep(Duration::from_millis(200)).await; 556 tokio::time::sleep(Duration::from_millis(200)).await;
507 557
508 // 4. Create state event referencing the HEAD commit (pr_commit) 558 // Step 2: Create and send state event → purgatory (no git data yet)
509 // After add_commit_to_repo, main points to pr_commit (which includes state_commit in history)
510 let clone_urls = [ 559 let clone_urls = [
511 format!( 560 format!(
512 "http://{}/{}/{}.git", 561 "http://{}/{}/{}.git",
@@ -521,11 +570,13 @@ async fn test_concurrent_state_and_pr_sync() {
521 format!("ws://{}", syncing_domain), 570 format!("ws://{}", syncing_domain),
522 ]; 571 ];
523 572
524 // State event references main at head_commit (the current HEAD) 573 // State event references main at pr_commit (HEAD after add_commit_to_repo).
574 // push_to_relay uses `git push --all` which pushes main -> pr_commit (HEAD),
575 // so the state event must reference pr_commit for push validation to succeed.
525 let state_event = create_state_event( 576 let state_event = create_state_event(
526 &owner_keys, 577 &owner_keys,
527 identifier, 578 identifier,
528 &[("main", &head_commit)], 579 &[("main", &pr_commit)],
529 &[], 580 &[],
530 &[&clone_urls[0], &clone_urls[1]], 581 &[&clone_urls[0], &clone_urls[1]],
531 &[&relay_urls[0], &relay_urls[1]], 582 &[&relay_urls[0], &relay_urls[1]],
@@ -534,20 +585,31 @@ async fn test_concurrent_state_and_pr_sync() {
534 585
535 let state_event_id = state_event.id; 586 let state_event_id = state_event.id;
536 587
537 // Send state event to source relay (goes to purgatory - no git data yet)
538 source_client 588 source_client
539 .send_event(&state_event) 589 .send_event(&state_event)
540 .await 590 .await
541 .expect("Failed to send state event to source"); 591 .expect("Failed to send state event to source");
542 592
543 // 5. Create PR event referencing the same commit (head_commit) 593 tokio::time::sleep(Duration::from_millis(200)).await;
544 // This simulates a PR that proposes the changes in head_commit 594
595 // Step 3: Push git data to source relay
596 // This promotes the announcement from StateOnly to Full AND releases state event
597 push_to_relay(temp_dir.path(), &source_relay.domain(), &npub, identifier)
598 .expect("Push to source should succeed");
599
600 // Wait for state event to be released from purgatory on source relay
601 wait_for_event_served(source_relay.url(), &state_event_id, Duration::from_secs(5))
602 .await
603 .expect("State event should be served on source relay after push");
604
605 // Step 4: Create and send PR event → purgatory
606 // NOW the announcement is promoted (Full), so PR events are accepted
545 let repo_coord = build_repo_coord(&owner_keys, identifier); 607 let repo_coord = build_repo_coord(&owner_keys, identifier);
546 608
547 let pr_event = create_pr_event( 609 let pr_event = create_pr_event(
548 &pr_author_keys, 610 &pr_author_keys,
549 &repo_coord, 611 &repo_coord,
550 &head_commit, 612 &pr_commit,
551 "Test PR for concurrent sync", 613 "Test PR for concurrent sync",
552 ) 614 )
553 .expect("Failed to create PR event"); 615 .expect("Failed to create PR event");
@@ -570,33 +632,25 @@ async fn test_concurrent_state_and_pr_sync() {
570 632
571 tokio::time::sleep(Duration::from_millis(200)).await; 633 tokio::time::sleep(Duration::from_millis(200)).await;
572 634
573 // 6. Push git data to source relay 635 // Step 5: Push PR commit to refs/nostr/<event-id> on source relay
574 // Push all branches (main contains both commits due to linear history) 636 // This releases the PR event from purgatory
575 push_to_relay(temp_dir.path(), &source_relay.domain(), &npub, identifier)
576 .expect("Push to source should succeed");
577
578 // Also push the PR ref
579 let pr_ref_name = format!("refs/nostr/{}", pr_event_id.to_hex()); 637 let pr_ref_name = format!("refs/nostr/{}", pr_event_id.to_hex());
580 push_ref_to_relay( 638 push_ref_to_relay(
581 temp_dir.path(), 639 temp_dir.path(),
582 &source_relay.domain(), 640 &source_relay.domain(),
583 &npub, 641 &npub,
584 identifier, 642 identifier,
585 &head_commit, 643 &pr_commit,
586 &pr_ref_name, 644 &pr_ref_name,
587 ) 645 )
588 .expect("Push PR ref to source should succeed"); 646 .expect("Push PR ref to source should succeed");
589 647
590 // After push, both events should be released from purgatory on source relay 648 // Wait for PR event to be released from purgatory on source relay
591 wait_for_event_served(source_relay.url(), &state_event_id, Duration::from_secs(5))
592 .await
593 .expect("State event should be served on source relay after push");
594
595 wait_for_event_served(source_relay.url(), &pr_event_id, Duration::from_secs(5)) 649 wait_for_event_served(source_relay.url(), &pr_event_id, Duration::from_secs(5))
596 .await 650 .await
597 .expect("PR event should be served on source relay after push"); 651 .expect("PR event should be served on source relay after push");
598 652
599 // 7. Start syncing relay (syncs from source) 653 // Step 6: Start syncing relay (syncs from source)
600 let syncing_relay = TestRelay::start_on_port_with_options( 654 let syncing_relay = TestRelay::start_on_port_with_options(
601 syncing_port, 655 syncing_port,
602 Some(source_relay.url().to_string()), 656 Some(source_relay.url().to_string()),
@@ -609,8 +663,13 @@ async fn test_concurrent_state_and_pr_sync() {
609 .await 663 .await
610 .expect("Sync connection should establish"); 664 .expect("Sync connection should establish");
611 665
612 // 8. Wait for BOTH events to be released on syncing relay 666 // Steps 7-11: Syncing relay syncs events
613 // The sync should fetch git data and release both events 667 // The sync should:
668 // a) Sync announcement → purgatory (StateOnly)
669 // b) Sync state event → purgatory
670 // c) Fetch git data → promotes announcement (Full) + releases state event
671 // d) Sync PR event → purgatory (announcement now Full)
672 // e) Fetch PR commit → releases PR event
614 let state_found = wait_for_event_served( 673 let state_found = wait_for_event_served(
615 syncing_relay.url(), 674 syncing_relay.url(),
616 &state_event_id, 675 &state_event_id,
@@ -629,18 +688,18 @@ async fn test_concurrent_state_and_pr_sync() {
629 688
630 assert!( 689 assert!(
631 pr_found.is_ok(), 690 pr_found.is_ok(),
632 "PR event should be served after sync fetches git data: {:?}", 691 "PR event should be served after sync fetches commit: {:?}",
633 pr_found.err() 692 pr_found.err()
634 ); 693 );
635 694
636 // 9. Verify refs are correct on syncing relay 695 // Verify refs are correct on syncing relay
637 // Check main branch points to head_commit (the HEAD) 696 // Check main branch points to pr_commit (HEAD after both commits)
638 let main_ref_correct = check_ref_at_commit( 697 let main_ref_correct = check_ref_at_commit(
639 &syncing_domain, 698 &syncing_domain,
640 &npub, 699 &npub,
641 identifier, 700 identifier,
642 "refs/heads/main", 701 "refs/heads/main",
643 &head_commit, 702 &pr_commit, // After push, main points to pr_commit (HEAD)
644 ) 703 )
645 .await 704 .await
646 .expect("Failed to check main ref"); 705 .expect("Failed to check main ref");
@@ -648,24 +707,24 @@ async fn test_concurrent_state_and_pr_sync() {
648 assert!( 707 assert!(
649 main_ref_correct, 708 main_ref_correct,
650 "main branch should point to HEAD commit ({})", 709 "main branch should point to HEAD commit ({})",
651 head_commit 710 pr_commit
652 ); 711 );
653 712
654 // Check refs/nostr/<event-id> points to the same commit 713 // Check refs/nostr/<event-id> points to pr_commit
655 let pr_ref_correct = check_ref_at_commit( 714 let pr_ref_correct = check_ref_at_commit(
656 &syncing_domain, 715 &syncing_domain,
657 &npub, 716 &npub,
658 identifier, 717 identifier,
659 &pr_ref_name, 718 &pr_ref_name,
660 &head_commit, 719 &pr_commit,
661 ) 720 )
662 .await 721 .await
663 .expect("Failed to check PR ref"); 722 .expect("Failed to check PR ref");
664 723
665 assert!( 724 assert!(
666 pr_ref_correct, 725 pr_ref_correct,
667 "refs/nostr/<event-id> should point to commit ({})", 726 "refs/nostr/<event-id> should point to PR commit ({})",
668 head_commit 727 pr_commit
669 ); 728 );
670 729
671 // Cleanup 730 // Cleanup
@@ -921,162 +980,43 @@ async fn test_pr_event_clone_tag_sync_with_partial_oid_aggregation_from_multiple
921 .expect("PR event should be served on mock_relay immediately"); 980 .expect("PR event should be served on mock_relay immediately");
922 981
923 // ======================================================================== 982 // ========================================================================
924 // Step 5: Start syncing_relay WITHOUT bootstrap and publish announcement directly 983 // Step 5: Start syncing_relay with source_grasp as bootstrap
925 // ======================================================================== 984 // ========================================================================
926 985
927 // Start syncing_relay with sync enabled but NO bootstrap relay 986 // Start syncing_relay with source_grasp as bootstrap relay.
928 // This tests relay discovery from announcement's `relays` tag 987 // Negentropy is disabled because MockRelay doesn't support NIP-77, and the
929 // Note: We disable negentropy because MockRelay doesn't support NIP-77, 988 // sync system doesn't properly fall back to REQ+EOSE when negentropy fails.
930 // and the sync system doesn't properly fall back to REQ+EOSE when negentropy fails. 989 //
990 // We do NOT publish the announcement directly to syncing_relay. Instead,
991 // syncing_relay discovers it via the bootstrap connection to source_grasp,
992 // which has the promoted announcement in its database.
931 let syncing_relay = TestRelay::start_on_port_with_options( 993 let syncing_relay = TestRelay::start_on_port_with_options(
932 syncing_port, 994 syncing_port,
933 None, // NO bootstrap - relay discovery via announcement tags 995 Some(source_grasp.url().to_string()), // Bootstrap from source_grasp
934 true, // Disable negentropy - MockRelay doesn't support NIP-77 996 true, // Disable negentropy - MockRelay doesn't support NIP-77
935 ) 997 )
936 .await; 998 .await;
937 999
938 // Publish announcement DIRECTLY to syncing_relay
939 // This triggers relay discovery from the announcement's `relays` tag
940 let syncing_client = Client::new(owner_keys.clone());
941 syncing_client
942 .add_relay(syncing_relay.url())
943 .await
944 .expect("Failed to add syncing_relay");
945 syncing_client.connect().await;
946 tokio::time::sleep(Duration::from_millis(500)).await;
947
948 syncing_client
949 .send_event(&announcement)
950 .await
951 .expect("Failed to send announcement to syncing_relay");
952 tokio::time::sleep(Duration::from_millis(200)).await;
953
954 // Wait for relay discovery and sync connections to establish
955 // syncing_relay should discover source_grasp and mock_relay from announcement's relays tag
956 println!("=== Waiting for sync connections ===");
957 println!("syncing_relay URL: {}", syncing_relay.url());
958 println!("source_grasp URL: {}", source_grasp.url());
959 println!("mock_relay URL: {}", mock_relay.url());
960 println!("git_server URL: {}", git_server.url());
961
962 wait_for_sync_connection(syncing_relay.url(), 2, Duration::from_secs(10))
963 .await
964 .expect(
965 "Sync connections should establish to discovered relays (source_grasp + mock_relay)",
966 );
967 println!("Sync connections established!");
968
969 // Debug: Check metrics to see what relays are connected
970 let metrics_url = syncing_relay
971 .url()
972 .replace("ws://", "http://")
973 .replace("/", "")
974 + "/metrics";
975 println!("Checking metrics at: {}", metrics_url);
976 if let Ok(response) = reqwest::get(&metrics_url).await {
977 if let Ok(metrics) = response.text().await {
978 // Print sync-related metrics
979 for line in metrics.lines() {
980 if line.contains("sync") && !line.starts_with('#') {
981 println!(" {}", line);
982 }
983 }
984 }
985 }
986
987 // Give some time for sync to happen
988 println!("Waiting 10s for events to sync...");
989 tokio::time::sleep(Duration::from_secs(10)).await;
990
991 // Check metrics again after waiting
992 println!("=== Checking metrics after sync wait ===");
993 if let Ok(response) = reqwest::get(&metrics_url).await {
994 if let Ok(metrics) = response.text().await {
995 for line in metrics.lines() {
996 if line.contains("sync") && !line.starts_with('#') {
997 println!(" {}", line);
998 }
999 }
1000 }
1001 }
1002
1003 // Debug: Check if PR event is still on mock_relay
1004 println!("=== Debug: Checking PR event on mock_relay ===");
1005 let pr_on_mock =
1006 wait_for_event_served(mock_relay.url(), &pr_event_id, Duration::from_secs(2)).await;
1007 println!("PR event on mock_relay: {:?}", pr_on_mock.is_ok());
1008 if let Ok(ref pr) = pr_on_mock {
1009 println!("PR event tags:");
1010 for tag in pr.tags.iter() {
1011 println!(" {:?}", tag.as_slice());
1012 }
1013 }
1014
1015 // Debug: Check repo coordinate
1016 let repo_coord = build_repo_coord(&owner_keys, identifier);
1017 println!("Expected repo coordinate: {}", repo_coord);
1018
1019 // Debug: Test if mock_relay responds to tag-based filter (Layer 2 style)
1020 println!("=== Debug: Testing mock_relay tag filter response ===");
1021 let test_client = Client::new(Keys::generate());
1022 test_client
1023 .add_relay(mock_relay.url())
1024 .await
1025 .expect("Failed to add mock_relay");
1026 test_client.connect().await;
1027 tokio::time::sleep(Duration::from_millis(500)).await;
1028
1029 // Build a Layer 2 style filter (by 'a' tag)
1030 let tag_filter =
1031 Filter::new().custom_tag(SingleLetterTag::lowercase(Alphabet::A), repo_coord.as_str());
1032 println!("Tag filter: {:?}", tag_filter);
1033
1034 let tag_results = test_client
1035 .fetch_events(tag_filter, Duration::from_secs(5))
1036 .await;
1037 match tag_results {
1038 Ok(events) => {
1039 println!("Tag filter returned {} events", events.len());
1040 for event in events.iter() {
1041 println!(" Event ID: {}, Kind: {}", event.id, event.kind.as_u16());
1042 }
1043 }
1044 Err(e) => {
1045 println!("Tag filter query failed: {:?}", e);
1046 }
1047 }
1048 test_client.disconnect().await;
1049
1050 // The syncing relay will: 1000 // The syncing relay will:
1051 // 1. Receive announcement directly (creates bare repo) 1001 // 1. Sync promoted announcement from source_grasp via bootstrap connection → purgatory (no local git data)
1052 // 2. Discover source_grasp and mock_relay from announcement's `relays` tag 1002 // 2. EOSE triggers StateOnly subscription → syncs state event from source_grasp → purgatory sync
1053 // 3. Connect to discovered relays 1003 // 3. Purgatory sync fetches commit_a from source_grasp clone URL → announcement + state promoted
1054 // 4. Sync state event from source_grasp → purgatory (no commit_a locally) 1004 // 4. SelfSubscriber sees promoted announcement → upgrades to Full → connects to mock_relay
1055 // 5. Sync PR event from mock_relay → purgatory (no commit_b locally) 1005 // 5. Syncs PR event from mock_relay → purgatory (no commit_b locally)
1056 // 6. Purgatory sync triggers 1006 // 6. Purgatory sync fetches commit_b from git_server via PR clone tag
1057 // 7. Fetches commit_a from source_grasp clone URL (from announcement clone tag) 1007 // 7. PR event promoted → served
1058 // 8. Fetches commit_b from git_server (from PR event's clone tag)
1059 // 9. Both events released when all OIDs available
1060 1008
1061 // ======================================================================== 1009 // ========================================================================
1062 // Step 6: Verify Results 1010 // Step 6: Verify Results
1063 // ======================================================================== 1011 // ========================================================================
1064 1012
1065 println!("=== Step 6: Verify Results ===");
1066 println!("State event ID: {}", state_event_id);
1067 println!("PR event ID: {}", pr_event_id);
1068 println!("commit_a: {}", commit_a);
1069 println!("commit_b: {}", commit_b);
1070
1071 // Wait for state event to be served on syncing_relay 1013 // Wait for state event to be served on syncing_relay
1072 println!("Waiting for state event on syncing_relay...");
1073 let state_found = wait_for_event_served( 1014 let state_found = wait_for_event_served(
1074 syncing_relay.url(), 1015 syncing_relay.url(),
1075 &state_event_id, 1016 &state_event_id,
1076 Duration::from_secs(30), 1017 Duration::from_secs(30),
1077 ) 1018 )
1078 .await; 1019 .await;
1079 println!("State event result: {:?}", state_found);
1080 assert!( 1020 assert!(
1081 state_found.is_ok(), 1021 state_found.is_ok(),
1082 "State event should be served on syncing_relay: {:?}", 1022 "State event should be served on syncing_relay: {:?}",
@@ -1084,10 +1024,8 @@ async fn test_pr_event_clone_tag_sync_with_partial_oid_aggregation_from_multiple
1084 ); 1024 );
1085 1025
1086 // Wait for PR event to be served on syncing_relay 1026 // Wait for PR event to be served on syncing_relay
1087 println!("Waiting for PR event on syncing_relay...");
1088 let pr_found = 1027 let pr_found =
1089 wait_for_event_served(syncing_relay.url(), &pr_event_id, Duration::from_secs(30)).await; 1028 wait_for_event_served(syncing_relay.url(), &pr_event_id, Duration::from_secs(30)).await;
1090 println!("PR event result: {:?}", pr_found);
1091 assert!( 1029 assert!(
1092 pr_found.is_ok(), 1030 pr_found.is_ok(),
1093 "PR event should be served on syncing_relay (fetched commit_b from git_server via PR clone tag): {:?}", 1031 "PR event should be served on syncing_relay (fetched commit_b from git_server via PR clone tag): {:?}",
@@ -1128,7 +1066,6 @@ async fn test_pr_event_clone_tag_sync_with_partial_oid_aggregation_from_multiple
1128 source_client.disconnect().await; 1066 source_client.disconnect().await;
1129 mock_client.disconnect().await; 1067 mock_client.disconnect().await;
1130 pr_client.disconnect().await; 1068 pr_client.disconnect().await;
1131 syncing_client.disconnect().await;
1132 git_server.stop().await; 1069 git_server.stop().await;
1133 mock_relay.stop().await; 1070 mock_relay.stop().await;
1134 syncing_relay.stop().await; 1071 syncing_relay.stop().await;
diff --git a/tests/sync/discovery.rs b/tests/sync/discovery.rs
index 8ed80b5..d45a290 100644
--- a/tests/sync/discovery.rs
+++ b/tests/sync/discovery.rs
@@ -3,10 +3,6 @@
3//! Tests for relay discovery from announcement events. 3//! Tests for relay discovery from announcement events.
4//! When a relay receives an announcement listing another relay, 4//! When a relay receives an announcement listing another relay,
5//! it should discover and connect to that relay to sync events. 5//! it should discover and connect to that relay to sync events.
6//!
7//! # Tests
8//! - Test 2: Direct Layer 3 discovery from Layer 2
9//! - Test 3: Recursive multi-hop Layer 3 discovery
10 6
11use std::time::Duration; 7use std::time::Duration;
12 8
@@ -62,29 +58,26 @@ async fn test_discovers_layer3_via_layer2() {
62 // 3. Create test keys 58 // 3. Create test keys
63 let keys = Keys::generate(); 59 let keys = Keys::generate();
64 60
65 // 4. Create a repository announcement that lists BOTH relays 61 // 4. Set up repository announcement on relay_a with git data
66 let announcement = create_repo_announcement( 62 // (purgatory requires git data before announcements are accepted)
67 &keys, 63 let repo_id = "test-repo-discovery";
68 &[&relay_a.domain(), &relay_b.domain()], 64 let domains = vec![relay_a.domain(), relay_b.domain()];
69 "test-repo-discovery", 65 let domain_refs: Vec<&str> = domains.iter().map(|s| s.as_str()).collect();
70 );
71 let announcement_id = announcement.id;
72 66
67 let (announcement, _git_dir_a) =
68 setup_announcement_on_relay(&relay_a, &keys, &domain_refs, repo_id).await;
69 let announcement_id = announcement.id;
73 println!( 70 println!(
74 "Created announcement {} (kind {})", 71 "Announcement {} set up on relay_a with git data",
75 announcement_id, 72 announcement_id
76 announcement.kind.as_u16()
77 ); 73 );
78 for tag in announcement.tags.iter() {
79 println!(" Tag: {:?}", tag.as_slice());
80 }
81 74
82 // 5. Build the repo coordinate for the 'a' tag in the patch 75 // 5. Build the repo coordinate for the 'a' tag in the patch
83 let repo_coord = format!( 76 let repo_coord = format!(
84 "{}:{}:{}", 77 "{}:{}:{}",
85 Kind::GitRepoAnnouncement.as_u16(), 78 Kind::GitRepoAnnouncement.as_u16(),
86 keys.public_key().to_hex(), 79 keys.public_key().to_hex(),
87 "test-repo-discovery" 80 repo_id
88 ); 81 );
89 82
90 // 6. Create a patch event (Layer 2) that references the announcement 83 // 6. Create a patch event (Layer 2) that references the announcement
@@ -97,22 +90,13 @@ async fn test_discovers_layer3_via_layer2() {
97 let patch_id = patch.id; 90 let patch_id = patch.id;
98 91
99 println!("Created patch {} (kind {})", patch_id, patch.kind.as_u16()); 92 println!("Created patch {} (kind {})", patch_id, patch.kind.as_u16());
100 for tag in patch.tags.iter() {
101 println!(" Tag: {:?}", tag.as_slice());
102 }
103 93
104 // 7. Send announcement and patch to relay_a ONLY 94 // 7. Send patch to relay_a
105 let client_a = TestClient::new(relay_a.url(), keys.clone()) 95 let client_a = TestClient::new(relay_a.url(), keys.clone())
106 .await 96 .await
107 .expect("Failed to connect to relay_a"); 97 .expect("Failed to connect to relay_a");
108 98
109 client_a 99 client_a
110 .send_event(&announcement)
111 .await
112 .expect("Failed to send announcement to relay_a");
113 println!("Announcement sent to relay_a");
114
115 client_a
116 .send_event(&patch) 100 .send_event(&patch)
117 .await 101 .await
118 .expect("Failed to send patch to relay_a"); 102 .expect("Failed to send patch to relay_a");
@@ -120,18 +104,10 @@ async fn test_discovers_layer3_via_layer2() {
120 104
121 client_a.disconnect().await; 105 client_a.disconnect().await;
122 106
123 // 8. Send announcement to relay_b directly (triggers discovery of relay_a) 107 // 8. Set up announcement on relay_b (triggers discovery of relay_a)
124 let client_b = TestClient::new(relay_b.url(), keys.clone()) 108 let (_announcement_b, _git_dir_b) =
125 .await 109 setup_announcement_on_relay(&relay_b, &keys, &domain_refs, repo_id).await;
126 .expect("Failed to connect to relay_b"); 110 println!("Announcement set up on relay_b (should trigger discovery of relay_a)");
127
128 client_b
129 .send_event(&announcement)
130 .await
131 .expect("Failed to send announcement to relay_b");
132 println!("Announcement sent to relay_b (should trigger discovery of relay_a)");
133
134 client_b.disconnect().await;
135 111
136 // 9. Wait for relay_b to discover relay_a and sync the patch 112 // 9. Wait for relay_b to discover relay_a and sync the patch
137 println!("Waiting 3s for relay_b to discover relay_a and sync patch..."); 113 println!("Waiting 3s for relay_b to discover relay_a and sync patch...");
@@ -197,19 +173,20 @@ async fn test_relay_discovery_via_announcements_with_historic_sync() {
197 // 3. Create test keys 173 // 3. Create test keys
198 let keys = Keys::generate(); 174 let keys = Keys::generate();
199 175
200 // 4. Create the event chain on relay_a: 176 // 4. Set up repository on relay_a with git data and a Layer 2 issue
201 177
202 // Layer 1: Repository announcement 178 // Layer 1: Set up announcement with git data
203 let announcement = create_repo_announcement( 179 let domains = vec![relay_a.domain(), relay_b.domain()];
204 &keys, 180 let domain_refs: Vec<&str> = domains.iter().map(|s| s.as_str()).collect();
205 &[&relay_a.domain(), &relay_b.domain()], 181 let repo_id = "test-repo-chain";
206 "test-repo-chain", 182
207 ); 183 let (announcement, _git_dir_a) =
184 setup_announcement_on_relay(&relay_a, &keys, &domain_refs, repo_id).await;
208 let announcement_id = announcement.id; 185 let announcement_id = announcement.id;
209 println!("Created announcement {} (Layer 1)", announcement_id); 186 println!("Announcement {} set up on relay_a with git data (Layer 1)", announcement_id);
210 187
211 // Build repo coordinate for Layer 2 reference 188 // Build repo coordinate for Layer 2 reference
212 let repo_coord = repo_coord(&keys, "test-repo-chain"); 189 let repo_coord = repo_coord(&keys, repo_id);
213 190
214 // Layer 2: Issue referencing the repo 191 // Layer 2: Issue referencing the repo
215 let issue = build_layer2_issue_event(&keys, &repo_coord, "Test issue for chain discovery") 192 let issue = build_layer2_issue_event(&keys, &repo_coord, "Test issue for chain discovery")
@@ -217,35 +194,23 @@ async fn test_relay_discovery_via_announcements_with_historic_sync() {
217 let issue_id = issue.id; 194 let issue_id = issue.id;
218 println!("Created issue {} (Layer 2)", issue_id); 195 println!("Created issue {} (Layer 2)", issue_id);
219 196
220 // 5. Send all events to relay_a 197 // 5. Send issue to relay_a
221 let client_a = TestClient::new(relay_a.url(), keys.clone()) 198 let client_a = TestClient::new(relay_a.url(), keys.clone())
222 .await 199 .await
223 .expect("Failed to connect to relay_a"); 200 .expect("Failed to connect to relay_a");
224 201
225 client_a 202 client_a
226 .send_event(&announcement)
227 .await
228 .expect("Failed to send announcement");
229 client_a
230 .send_event(&issue) 203 .send_event(&issue)
231 .await 204 .await
232 .expect("Failed to send issue"); 205 .expect("Failed to send issue");
233 206
234 println!("Events sent to relay_a"); 207 println!("Issue sent to relay_a");
235 client_a.disconnect().await; 208 client_a.disconnect().await;
236 209
237 // 6. Send only the announcement to relay_b (triggers discovery) 210 // 6. Set up announcement on relay_b (triggers discovery of relay_a)
238 let client_b = TestClient::new(relay_b.url(), keys.clone()) 211 let (_announcement_b, _git_dir_b) =
239 .await 212 setup_announcement_on_relay(&relay_b, &keys, &domain_refs, repo_id).await;
240 .expect("Failed to connect to relay_b"); 213 println!("Announcement set up on relay_b (should trigger discovery of relay_a)");
241
242 client_b
243 .send_event(&announcement)
244 .await
245 .expect("Failed to send announcement to relay_b");
246 println!("Announcement sent to relay_b (should trigger discovery)");
247
248 client_b.disconnect().await;
249 214
250 // 7. Wait for sync 215 // 7. Wait for sync
251 println!("Waiting 3s for Layer 2 sync..."); 216 println!("Waiting 3s for Layer 2 sync...");
@@ -271,163 +236,3 @@ async fn test_relay_discovery_via_announcements_with_historic_sync() {
271 ); 236 );
272} 237}
273 238
274/// Test 3: 3-relay recursive discovery - relay discovers third relay through bootstrap
275///
276/// Scenario:
277/// ```text
278/// relay_a (SUT) relay_b (bootstrap) relay_c (discovered)
279/// │ │ │
280/// │ │ has announcement_x │ has announcement_y
281/// │ │ listing A+B+C │ listing A+C
282/// │ │ │
283/// ├────connect──────────► │
284/// │◄───sync announcement_x───────────────────────
285/// │ │
286/// │ discovers relay_c from announcement_x │
287/// │ │
288/// ├─────────────connect─────────────────────────►
289/// │◄────────────sync announcement_y─────────────┘
290/// ```
291///
292/// This tests that relay_a:
293/// 1. Connects to relay_b (configured as bootstrap)
294/// 2. Receives announcement_x which lists relay_c
295/// 3. Discovers and connects to relay_c
296/// 4. Syncs announcement_y from relay_c
297///
298#[tokio::test]
299async fn test_recursive_relay_discovery_via_announcements_with_historic_sync() {
300 // 1. Start all three relays
301
302 // relay_b - will be the bootstrap relay, has announcement_x
303 let relay_b = TestRelay::start().await;
304 println!(
305 "relay_b (bootstrap) started at {} (domain: {})",
306 relay_b.url(),
307 relay_b.domain()
308 );
309
310 // relay_c - will be discovered via announcement_x, has announcement_y
311 let relay_c = TestRelay::start().await;
312 println!(
313 "relay_c (to be discovered) started at {} (domain: {})",
314 relay_c.url(),
315 relay_c.domain()
316 );
317
318 // relay_a - SUT, starts with relay_b as bootstrap
319 let relay_a = TestRelay::start_with_sync(Some(relay_b.url().to_string())).await;
320 println!(
321 "relay_a (SUT) started at {} (domain: {})",
322 relay_a.url(),
323 relay_a.domain()
324 );
325
326 // 2. Create test keys (one for each announcement)
327 let keys_x = Keys::generate();
328 let keys_y = Keys::generate();
329
330 // 3. Create announcement_x on relay_b (lists all three relays: A+B+C)
331 let announcement_x = create_repo_announcement(
332 &keys_x,
333 &[&relay_a.domain(), &relay_b.domain(), &relay_c.domain()],
334 "repo-x-all-relays",
335 );
336 let announcement_x_id = announcement_x.id;
337 println!("Created announcement_x {} listing A+B+C", announcement_x_id);
338 for tag in announcement_x.tags.iter() {
339 println!(" Tag: {:?}", tag.as_slice());
340 }
341
342 // 4. Create announcement_y on relay_c (lists only A+C, NOT B)
343 let announcement_y = create_repo_announcement(
344 &keys_y,
345 &[&relay_a.domain(), &relay_c.domain()],
346 "repo-y-ac-only",
347 );
348 let announcement_y_id = announcement_y.id;
349 println!(
350 "Created announcement_y {} listing A+C only",
351 announcement_y_id
352 );
353 for tag in announcement_y.tags.iter() {
354 println!(" Tag: {:?}", tag.as_slice());
355 }
356
357 // 5. Send announcement_x to relay_b only
358 let client_b = TestClient::new(relay_b.url(), keys_x.clone())
359 .await
360 .expect("Failed to connect to relay_b");
361
362 client_b
363 .send_event(&announcement_x)
364 .await
365 .expect("Failed to send announcement_x to relay_b");
366 println!("announcement_x sent to relay_b");
367
368 client_b.disconnect().await;
369
370 // 6. Send announcement_y to relay_c only
371 let client_c = TestClient::new(relay_c.url(), keys_y.clone())
372 .await
373 .expect("Failed to connect to relay_c");
374
375 client_c
376 .send_event(&announcement_y)
377 .await
378 .expect("Failed to send announcement_y to relay_c");
379 println!("announcement_y sent to relay_c");
380
381 client_c.disconnect().await;
382
383 // 7. Wait for relay_a to:
384 // - Sync from bootstrap relay_b (gets announcement_x)
385 // - Discover relay_c from announcement_x's relays tag
386 // - Connect to relay_c and sync announcement_y
387 println!("Waiting 5s for recursive relay discovery...");
388 tokio::time::sleep(Duration::from_secs(5)).await;
389
390 // 8. Verify announcement_x was synced to relay_a (from bootstrap relay_b)
391 let filter_x = Filter::new()
392 .kind(Kind::GitRepoAnnouncement)
393 .author(keys_x.public_key());
394
395 let announcement_x_synced =
396 wait_for_event_on_relay(relay_a.url(), filter_x, Duration::from_secs(5)).await;
397
398 println!(
399 "announcement_x {} synced to relay_a: {}",
400 announcement_x_id, announcement_x_synced
401 );
402
403 // 9. Verify announcement_y was synced to relay_a (from discovered relay_c)
404 let filter_y = Filter::new()
405 .kind(Kind::GitRepoAnnouncement)
406 .author(keys_y.public_key());
407
408 let announcement_y_synced =
409 wait_for_event_on_relay(relay_a.url(), filter_y, Duration::from_secs(5)).await;
410
411 println!(
412 "announcement_y {} synced to relay_a: {}",
413 announcement_y_id, announcement_y_synced
414 );
415
416 // 10. Cleanup
417 relay_a.stop().await;
418 relay_b.stop().await;
419 relay_c.stop().await;
420
421 // 11. Assertions
422 assert!(
423 announcement_x_synced,
424 "announcement_x {} should have synced from bootstrap relay_b to relay_a",
425 announcement_x_id
426 );
427
428 assert!(
429 announcement_y_synced,
430 "announcement_y {} should have synced from discovered relay_c to relay_a (recursive discovery)",
431 announcement_y_id
432 );
433}
diff --git a/tests/sync/historic_sync.rs b/tests/sync/historic_sync.rs
index aec2819..723b776 100644
--- a/tests/sync/historic_sync.rs
+++ b/tests/sync/historic_sync.rs
@@ -224,34 +224,24 @@ async fn test_history_sync_without_negentropy() {
224 // Create keys 224 // Create keys
225 let keys = Keys::generate(); 225 let keys = Keys::generate();
226 226
227 // Create announcement listing BOTH relay domains 227 // Set up announcement on source with git data
228 // This event will exist on source BEFORE syncing relay ever connects 228 // (purgatory requires git data before announcements are accepted)
229 let announcement = create_repo_announcement( 229 let domains = vec![source.domain(), syncing_domain.clone()];
230 let domain_refs: Vec<&str> = domains.iter().map(|s| s.as_str()).collect();
231 let (announcement, _git_dir) = setup_announcement_on_relay(
232 &source,
230 &keys, 233 &keys,
231 &[&source.domain(), &syncing_domain], 234 &domain_refs,
232 "test-repo-history-no-negentropy", 235 "test-repo-history-no-negentropy",
233 ); 236 )
237 .await;
234 let announcement_id = announcement.id; 238 let announcement_id = announcement.id;
235 239
236 println!( 240 println!(
237 "Created announcement {} (kind {})", 241 "Announcement {} set up on source with git data (event exists BEFORE syncing relay connects)",
238 announcement_id, 242 announcement_id
239 announcement.kind.as_u16()
240 ); 243 );
241 244
242 // Send announcement to source (event now exists BEFORE syncing relay connects)
243 let client = TestClient::new(source.url(), keys.clone())
244 .await
245 .expect("Failed to connect to source");
246
247 client
248 .send_event(&announcement)
249 .await
250 .expect("Failed to send announcement to source");
251 println!("Announcement sent to source (event exists BEFORE syncing relay connects)");
252
253 client.disconnect().await;
254
255 // Wait to ensure event is stored 245 // Wait to ensure event is stored
256 tokio::time::sleep(Duration::from_millis(500)).await; 246 tokio::time::sleep(Duration::from_millis(500)).await;
257 247
diff --git a/tests/sync/live_sync.rs b/tests/sync/live_sync.rs
index 8ee3119..4289004 100644
--- a/tests/sync/live_sync.rs
+++ b/tests/sync/live_sync.rs
@@ -56,43 +56,24 @@ async fn test_live_sync_layer2_events() {
56 // 3. Create test keys 56 // 3. Create test keys
57 let keys = Keys::generate(); 57 let keys = Keys::generate();
58 58
59 // 4. Create a repository announcement that lists BOTH relays 59 // 4. Create a repository announcement on both relays with git data
60 // (purgatory requires git data before announcements are accepted)
60 let repo_id = "test-repo-live-l2"; 61 let repo_id = "test-repo-live-l2";
61 let announcement = 62 let domains = vec![relay_a.domain(), relay_b.domain()];
62 create_repo_announcement(&keys, &[&relay_a.domain(), &relay_b.domain()], repo_id); 63 let domain_refs: Vec<&str> = domains.iter().map(|s| s.as_str()).collect();
63 64
64 println!( 65 let (_announcement, _git_dir_a) =
65 "Created announcement {} (kind {})", 66 setup_announcement_on_relay(&relay_a, &keys, &domain_refs, repo_id).await;
66 announcement.id, 67 println!("Announcement set up on relay_a with git data");
67 announcement.kind.as_u16()
68 );
69
70 // 5. Send announcement to relay_a
71 let client_a = TestClient::new(relay_a.url(), keys.clone())
72 .await
73 .expect("Failed to connect to relay_a");
74
75 client_a
76 .send_event(&announcement)
77 .await
78 .expect("Failed to send announcement to relay_a");
79 println!("Announcement sent to relay_a");
80
81 // 6. Send announcement to relay_b (triggers discovery of relay_a)
82 let client_b = TestClient::new(relay_b.url(), keys.clone())
83 .await
84 .expect("Failed to connect to relay_b");
85 68
86 client_b 69 let (_announcement_b, _git_dir_b) =
87 .send_event(&announcement) 70 setup_announcement_on_relay(&relay_b, &keys, &domain_refs, repo_id).await;
88 .await 71 println!("Announcement set up on relay_b with git data (triggers discovery)");
89 .expect("Failed to send announcement to relay_b");
90 println!("Announcement sent to relay_b (triggers discovery)");
91 72
92 // 7. Wait for discovery to complete 73 // 5. Wait for discovery to complete
93 tokio::time::sleep(Duration::from_secs(1)).await; 74 tokio::time::sleep(Duration::from_secs(1)).await;
94 75
95 // 8. Create and send a Layer 2 issue event (using helper) 76 // 6. Create and send a Layer 2 issue event (using helper)
96 let repo_coordinate = repo_coord(&keys, repo_id); 77 let repo_coordinate = repo_coord(&keys, repo_id);
97 let issue = build_layer2_issue_event(&keys, &repo_coordinate, "Test Issue for Live Sync") 78 let issue = build_layer2_issue_event(&keys, &repo_coordinate, "Test Issue for Live Sync")
98 .expect("Failed to create issue event"); 79 .expect("Failed to create issue event");
@@ -104,6 +85,10 @@ async fn test_live_sync_layer2_events() {
104 } 85 }
105 86
106 // Send issue to relay_a only 87 // Send issue to relay_a only
88 let client_a = TestClient::new(relay_a.url(), keys.clone())
89 .await
90 .expect("Failed to connect to relay_a");
91
107 client_a 92 client_a
108 .send_event(&issue) 93 .send_event(&issue)
109 .await 94 .await
@@ -111,7 +96,6 @@ async fn test_live_sync_layer2_events() {
111 println!("Issue sent to relay_a"); 96 println!("Issue sent to relay_a");
112 97
113 client_a.disconnect().await; 98 client_a.disconnect().await;
114 client_b.disconnect().await;
115 99
116 // 9. Wait and verify event syncs to relay_b 100 // 9. Wait and verify event syncs to relay_b
117 let filter = Filter::new() 101 let filter = Filter::new()
@@ -166,30 +150,19 @@ async fn test_live_sync_layer3_events() {
166 150
167 let keys = Keys::generate(); 151 let keys = Keys::generate();
168 152
169 // 2. Create and send repository announcement to both relays 153 // 2. Create and send repository announcement to both relays with git data
154 // (purgatory requires git data before announcements are accepted)
170 let repo_id = "test-repo-live-l3"; 155 let repo_id = "test-repo-live-l3";
171 let announcement = 156 let domains = vec![relay_a.domain(), relay_b.domain()];
172 create_repo_announcement(&keys, &[&relay_a.domain(), &relay_b.domain()], repo_id); 157 let domain_refs: Vec<&str> = domains.iter().map(|s| s.as_str()).collect();
173 158
174 let client_a = TestClient::new(relay_a.url(), keys.clone()) 159 let (_announcement, _git_dir_a) =
175 .await 160 setup_announcement_on_relay(&relay_a, &keys, &domain_refs, repo_id).await;
176 .expect("Failed to connect to relay_a"); 161 println!("Announcement set up on relay_a with git data");
177 162
178 let client_b = TestClient::new(relay_b.url(), keys.clone()) 163 let (_announcement_b, _git_dir_b) =
179 .await 164 setup_announcement_on_relay(&relay_b, &keys, &domain_refs, repo_id).await;
180 .expect("Failed to connect to relay_b"); 165 println!("Announcement set up on relay_b with git data (triggers discovery)");
181
182 client_a
183 .send_event(&announcement)
184 .await
185 .expect("Failed to send announcement to relay_a");
186 println!("Announcement sent to relay_a");
187
188 client_b
189 .send_event(&announcement)
190 .await
191 .expect("Failed to send announcement to relay_b");
192 println!("Announcement sent to relay_b (triggers discovery)");
193 166
194 // 3. Wait for discovery 167 // 3. Wait for discovery
195 tokio::time::sleep(Duration::from_secs(1)).await; 168 tokio::time::sleep(Duration::from_secs(1)).await;
@@ -200,6 +173,10 @@ async fn test_live_sync_layer3_events() {
200 .expect("Failed to create issue"); 173 .expect("Failed to create issue");
201 let issue_id = issue.id; 174 let issue_id = issue.id;
202 175
176 let client_a = TestClient::new(relay_a.url(), keys.clone())
177 .await
178 .expect("Failed to connect to relay_a");
179
203 client_a 180 client_a
204 .send_event(&issue) 181 .send_event(&issue)
205 .await 182 .await
@@ -243,7 +220,6 @@ async fn test_live_sync_layer3_events() {
243 println!("Issue synced to relay_b: {}", issue_synced); 220 println!("Issue synced to relay_b: {}", issue_synced);
244 221
245 client_a.disconnect().await; 222 client_a.disconnect().await;
246 client_b.disconnect().await;
247 223
248 // 7. Wait and verify comment syncs to relay_b 224 // 7. Wait and verify comment syncs to relay_b
249 let comment_filter = Filter::new() 225 let comment_filter = Filter::new()
@@ -343,29 +319,17 @@ async fn test_live_sync_event_ordering() {
343 319
344 let keys = Keys::generate(); 320 let keys = Keys::generate();
345 321
346 // 2. Create and send repository announcement to both relays 322 // 2. Create and send repository announcement to both relays with git data
323 // (purgatory requires git data before announcements are accepted)
347 let repo_id = "test-repo-ordering"; 324 let repo_id = "test-repo-ordering";
348 let announcement = 325 let domains = vec![relay_a.domain(), relay_b.domain()];
349 create_repo_announcement(&keys, &[&relay_a.domain(), &relay_b.domain()], repo_id); 326 let domain_refs: Vec<&str> = domains.iter().map(|s| s.as_str()).collect();
350 327
351 let client_a = TestClient::new(relay_a.url(), keys.clone()) 328 let (_announcement, _git_dir_a) =
352 .await 329 setup_announcement_on_relay(&relay_a, &keys, &domain_refs, repo_id).await;
353 .expect("Failed to connect to relay_a"); 330 let (_announcement_b, _git_dir_b) =
354 331 setup_announcement_on_relay(&relay_b, &keys, &domain_refs, repo_id).await;
355 let client_b = TestClient::new(relay_b.url(), keys.clone()) 332 println!("Announcements set up on both relays with git data");
356 .await
357 .expect("Failed to connect to relay_b");
358
359 client_a
360 .send_event(&announcement)
361 .await
362 .expect("Failed to send announcement to relay_a");
363
364 client_b
365 .send_event(&announcement)
366 .await
367 .expect("Failed to send announcement to relay_b");
368 println!("Announcements sent to both relays");
369 333
370 // 3. Wait for discovery 334 // 3. Wait for discovery
371 tokio::time::sleep(Duration::from_secs(1)).await; 335 tokio::time::sleep(Duration::from_secs(1)).await;
@@ -375,6 +339,10 @@ async fn test_live_sync_event_ordering() {
375 let mut issue_ids = Vec::new(); 339 let mut issue_ids = Vec::new();
376 let mut expected_order_timestamps = Vec::new(); 340 let mut expected_order_timestamps = Vec::new();
377 341
342 let client_a = TestClient::new(relay_a.url(), keys.clone())
343 .await
344 .expect("Failed to connect to relay_a");
345
378 for i in 1..=3 { 346 for i in 1..=3 {
379 let issue = build_layer2_issue_event( 347 let issue = build_layer2_issue_event(
380 &keys, 348 &keys,
@@ -402,7 +370,6 @@ async fn test_live_sync_event_ordering() {
402 } 370 }
403 371
404 client_a.disconnect().await; 372 client_a.disconnect().await;
405 client_b.disconnect().await;
406 373
407 // 5. Wait for all events to sync 374 // 5. Wait for all events to sync
408 tokio::time::sleep(Duration::from_secs(3)).await; 375 tokio::time::sleep(Duration::from_secs(3)).await;
diff --git a/tests/sync/maintainer_reprocessing.rs b/tests/sync/maintainer_reprocessing.rs
index df1bf78..ff1eb43 100644
--- a/tests/sync/maintainer_reprocessing.rs
+++ b/tests/sync/maintainer_reprocessing.rs
@@ -2,6 +2,25 @@
2//! 2//!
3//! Tests the two-tier rejected events index and immediate re-processing of 3//! Tests the two-tier rejected events index and immediate re-processing of
4//! maintainer announcements when owner announcements are accepted. 4//! maintainer announcements when owner announcements are accepted.
5//!
6//! ## Test design
7//!
8//! Announcements now require git data before they are released from purgatory and
9//! served to other relays. The hot-cache re-processing path we want to exercise is:
10//!
11//! relay_b syncs maintainer announcement from relay_a
12//! → write policy rejects it (no owner announcement in DB yet)
13//! → event stored in hot cache
14//! owner git push to relay_b promotes owner announcement from purgatory
15//! → our new code calls rejected_events_index.invalidate_and_get()
16//! → maintainer announcement re-processed and accepted
17//!
18//! To guarantee the maintainer announcements arrive at relay_b *before* the owner
19//! git push, relay_b is started with relay_a as its bootstrap relay. That way
20//! relay_b's SyncManager connects to relay_a immediately and syncs whatever is
21//! already in relay_a's DB. We push the maintainer git data first (so the
22//! announcements are in relay_a's DB), wait briefly for the sync round-trip, then
23//! send the owner announcement + git push.
5 24
6use std::time::Duration; 25use std::time::Duration;
7 26
@@ -9,66 +28,91 @@ use nostr_sdk::prelude::*;
9 28
10use crate::common::{sync_helpers::*, TestRelay}; 29use crate::common::{sync_helpers::*, TestRelay};
11 30
12/// Test that maintainer announcements are re-processed immediately when owner announcement accepted 31/// Test that a maintainer announcement is re-processed immediately when the owner
32/// announcement is promoted from purgatory via a git push.
13/// 33///
14/// Flow: 34/// Flow:
15/// 1. relay_a: Maintainer sends announcement (gets rejected - doesn't list relay_b) 35/// 1. relay_a: Maintainer sends announcement + git data → accepted into relay_a's DB
16/// 2. relay_b: Owner sends announcement (lists relay_a + maintainer) 36/// 2. relay_b (bootstrapped from relay_a): SyncManager syncs maintainer announcement
17/// 3. relay_b syncs from relay_a, maintainer announcement enters rejected index 37/// → rejected by write policy (no owner in DB) → stored in hot cache
18/// 4. relay_b processes owner announcement, invalidates and re-processes maintainer announcement 38/// 3. relay_b: Owner sends announcement → purgatory (no git data yet)
39/// 4. relay_b: Owner git push → owner announcement promoted from purgatory
40/// → hot-cache re-processing fires → maintainer announcement accepted
19/// 5. Both announcements should be in relay_b's database 41/// 5. Both announcements should be in relay_b's database
20///
21/// Expected time: <5 seconds (vs 24 hours without hot cache)
22#[tokio::test] 42#[tokio::test]
23async fn test_maintainer_announcement_reprocessed_immediately() { 43async fn test_maintainer_announcement_reprocessed_immediately() {
24 // Start relay_a (where maintainer announcement will be sent) 44 // Start relay_a (where maintainer announcement will be sent)
25 let relay_a = TestRelay::start().await; 45 let relay_a = TestRelay::start().await;
26 println!("relay_a started at {}", relay_a.url()); 46 println!("relay_a started at {}", relay_a.url());
27 47
28 // Start relay_b with sync enabled (will sync from relay_a)
29 let relay_b = TestRelay::start_with_sync(None).await;
30 println!("relay_b started at {}", relay_b.url());
31
32 // Create keys 48 // Create keys
33 let owner_keys = Keys::generate(); 49 let owner_keys = Keys::generate();
34 let maintainer_keys = Keys::generate(); 50 let maintainer_keys = Keys::generate();
35
36 let identifier = "test-repo"; 51 let identifier = "test-repo";
37 52
38 let start = std::time::Instant::now(); 53 // Step 1: Send maintainer announcement to relay_a then push git data so it lands in
39 54 // relay_a's DB. The announcement lists relay_a only (not relay_b), so relay_b's write
40 // Step 1: Send maintainer announcement to relay_a (will be rejected - doesn't list relay_b) 55 // policy will reject it when it arrives via sync.
41 let client_a = TestClient::new(relay_a.url(), maintainer_keys.clone()) 56 let maintainer_npub = maintainer_keys
42 .await 57 .public_key()
43 .expect("Failed to connect to relay_a"); 58 .to_bech32()
44 59 .expect("Failed to get npub");
45 let maintainer_announcement = 60 let maintainer_announcement =
46 EventBuilder::new(Kind::GitRepoAnnouncement, "Maintainer's repository") 61 EventBuilder::new(Kind::GitRepoAnnouncement, "Maintainer's repository")
47 .tags(vec![ 62 .tags(vec![
48 Tag::identifier(identifier), 63 Tag::identifier(identifier),
49 Tag::custom( 64 Tag::custom(
50 TagKind::custom("clone"), 65 TagKind::custom("clone"),
51 vec![format!("https://{}/{}.git", relay_a.domain(), identifier)], 66 vec![format!(
67 "http://{}/{}/{}.git",
68 relay_a.domain(),
69 maintainer_npub,
70 identifier
71 )],
72 ),
73 Tag::custom(
74 TagKind::custom("relays"),
75 vec![relay_a.url().to_string()],
52 ), 76 ),
53 Tag::custom(TagKind::custom("relays"), vec![relay_a.url().to_string()]),
54 ]) 77 ])
55 .sign_with_keys(&maintainer_keys) 78 .sign_with_keys(&maintainer_keys)
56 .unwrap(); 79 .unwrap();
80 send_to_relay(&relay_a, &maintainer_announcement).await.unwrap();
81 let _git_dir_maintainer =
82 push_git_data_to_relay(&relay_a, &maintainer_keys, identifier, &[&relay_a.domain()])
83 .await;
84 println!("✓ Maintainer announcement + git data pushed to relay_a");
85
86 // Step 2: Start relay_b with relay_a as bootstrap so its SyncManager connects immediately.
87 // relay_b's initial negentropy sync will pick up the maintainer announcement and reject it
88 // (no owner announcement in relay_b's DB yet), storing it in the hot cache.
89 let relay_b = TestRelay::start_with_sync(Some(relay_a.url().to_string())).await;
90 println!("relay_b started at {}", relay_b.url());
57 91
58 client_a.send_event(&maintainer_announcement).await.unwrap(); 92 // Give relay_b's SyncManager time to complete the initial negentropy sync with relay_a.
59 println!("✓ Maintainer announcement sent to relay_a"); 93 tokio::time::sleep(Duration::from_secs(3)).await;
94 println!("✓ relay_b synced from relay_a (maintainer announcement should be in hot cache)");
60 95
61 // Step 2: Send owner announcement to relay_b (lists relay_a + maintainer) 96 let start = std::time::Instant::now();
62 let client_b = TestClient::new(relay_b.url(), owner_keys.clone()) 97
63 .await 98 // Step 3: Send owner announcement to relay_b → goes to purgatory (no git data yet).
64 .expect("Failed to connect to relay_b"); 99 // The announcement lists relay_a + relay_b and names the maintainer.
100 let owner_npub = owner_keys
101 .public_key()
102 .to_bech32()
103 .expect("Failed to get npub");
65 104
66 let owner_announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "Owner's repository") 105 let owner_announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "Owner's repository")
67 .tags(vec![ 106 .tags(vec![
68 Tag::identifier(identifier), 107 Tag::identifier(identifier),
69 Tag::custom( 108 Tag::custom(
70 TagKind::custom("clone"), 109 TagKind::custom("clone"),
71 vec![format!("https://{}/{}.git", relay_b.domain(), identifier)], 110 vec![format!(
111 "http://{}/{}/{}.git",
112 relay_b.domain(),
113 owner_npub,
114 identifier
115 )],
72 ), 116 ),
73 Tag::custom( 117 Tag::custom(
74 TagKind::custom("relays"), 118 TagKind::custom("relays"),
@@ -82,15 +126,22 @@ async fn test_maintainer_announcement_reprocessed_immediately() {
82 .sign_with_keys(&owner_keys) 126 .sign_with_keys(&owner_keys)
83 .unwrap(); 127 .unwrap();
84 128
85 client_b.send_event(&owner_announcement).await.unwrap(); 129 send_to_relay(&relay_b, &owner_announcement).await.unwrap();
86 println!("✓ Owner announcement sent to relay_b"); 130 println!("✓ Owner announcement sent to relay_b (now in purgatory)");
87 131
88 // Step 3: Wait for sync and re-processing (relay_b discovers relay_a, syncs, re-processes) 132 // Step 4: Push owner git data to relay_b.
89 tokio::time::sleep(Duration::from_secs(3)).await; 133 // This promotes the owner announcement from purgatory, which triggers hot-cache
134 // re-processing of the maintainer announcement via our new code path.
135 let _git_dir_owner =
136 push_git_data_to_relay(&relay_b, &owner_keys, identifier, &[&relay_b.domain()]).await;
137 println!("✓ Owner git data pushed to relay_b (owner announcement promoted, hot cache re-processed)");
138
139 // Step 5: Wait briefly for async processing to complete.
140 tokio::time::sleep(Duration::from_secs(1)).await;
90 141
91 let elapsed = start.elapsed(); 142 let elapsed = start.elapsed();
92 143
93 // Step 4: Verify both announcements are in relay_b's database 144 // Step 6: Verify both announcements are in relay_b's database.
94 let owner_filter = Filter::new() 145 let owner_filter = Filter::new()
95 .kind(Kind::GitRepoAnnouncement) 146 .kind(Kind::GitRepoAnnouncement)
96 .author(owner_keys.public_key()) 147 .author(owner_keys.public_key())
@@ -112,17 +163,14 @@ async fn test_maintainer_announcement_reprocessed_immediately() {
112 "Maintainer announcement should be re-processed and accepted in relay_b" 163 "Maintainer announcement should be re-processed and accepted in relay_b"
113 ); 164 );
114 165
115 // Step 5: Verify it happened quickly (not 24 hours!)
116 assert!( 166 assert!(
117 elapsed.as_secs() < 10, 167 elapsed.as_secs() < 15,
118 "Re-processing should happen in <10 seconds, took {:?}", 168 "Re-processing should happen in <15 seconds, took {:?}",
119 elapsed 169 elapsed
120 ); 170 );
121 171
122 println!("✅ Maintainer announcement re-processed in {:?}", elapsed); 172 println!("✅ Maintainer announcement re-processed in {:?}", elapsed);
123 173
124 client_a.disconnect().await;
125 client_b.disconnect().await;
126 relay_a.stop().await; 174 relay_a.stop().await;
127 relay_b.stop().await; 175 relay_b.stop().await;
128} 176}
@@ -227,13 +275,16 @@ async fn test_maintainer_announcement_cold_index_prevents_refetch() {
227 relay.stop().await; 275 relay.stop().await;
228} 276}
229 277
230/// Test multiple maintainers are all re-processed when owner announcement accepted 278/// Test that all maintainer announcements are re-processed when the owner announcement
279/// is promoted from purgatory via a git push.
231/// 280///
232/// Flow: 281/// Flow:
233/// 1. relay_a: Three maintainers send announcements (get rejected - don't list relay_b) 282/// 1. relay_a: Three maintainers send announcements + git data → in relay_a's DB
234/// 2. relay_b: Owner sends announcement (lists relay_a + all three maintainers) 283/// 2. relay_b (bootstrapped from relay_a): SyncManager syncs all three maintainer
235/// 3. relay_b syncs from relay_a, all maintainer announcements enter rejected index 284/// announcements → all rejected (no owner in DB) → all in hot cache
236/// 4. relay_b processes owner announcement, invalidates and re-processes all maintainer announcements 285/// 3. relay_b: Owner sends announcement → purgatory
286/// 4. relay_b: Owner git push → owner promoted → hot-cache re-processing fires for
287/// all three maintainers
237/// 5. All four announcements should be in relay_b's database 288/// 5. All four announcements should be in relay_b's database
238#[tokio::test] 289#[tokio::test]
239async fn test_multiple_maintainers_all_reprocessed() { 290async fn test_multiple_maintainers_all_reprocessed() {
@@ -241,57 +292,113 @@ async fn test_multiple_maintainers_all_reprocessed() {
241 let relay_a = TestRelay::start().await; 292 let relay_a = TestRelay::start().await;
242 println!("relay_a started at {}", relay_a.url()); 293 println!("relay_a started at {}", relay_a.url());
243 294
244 // Start relay_b with sync enabled (will sync from relay_a)
245 let relay_b = TestRelay::start_with_sync(None).await;
246 println!("relay_b started at {}", relay_b.url());
247
248 // Create keys 295 // Create keys
249 let owner_keys = Keys::generate(); 296 let owner_keys = Keys::generate();
250 let maintainer1_keys = Keys::generate(); 297 let maintainer1_keys = Keys::generate();
251 let maintainer2_keys = Keys::generate(); 298 let maintainer2_keys = Keys::generate();
252 let maintainer3_keys = Keys::generate(); 299 let maintainer3_keys = Keys::generate();
253 300
254 let identifier = "multi-maintainer-repo"; 301 // Use a unique identifier per test run to avoid cross-test interference when
255 302 // tests run in parallel (each test gets its own namespace on relay_a).
256 // Step 1: Send three maintainer announcements to relay_a 303 let identifier = &format!(
257 let client_a = TestClient::new(relay_a.url(), maintainer1_keys.clone()) 304 "multi-maintainer-repo-{}",
258 .await 305 owner_keys.public_key().to_hex()[..8].to_string()
259 .expect("Failed to connect to relay_a"); 306 );
260 307
308 // Step 1: Send each maintainer announcement to relay_a then push git data so all three
309 // land in relay_a's DB. Each announcement lists relay_a only, so relay_b will reject
310 // them when syncing (no owner announcement in relay_b's DB yet).
311 let mut git_dirs = Vec::new();
261 for (idx, maintainer_keys) in [&maintainer1_keys, &maintainer2_keys, &maintainer3_keys] 312 for (idx, maintainer_keys) in [&maintainer1_keys, &maintainer2_keys, &maintainer3_keys]
262 .iter() 313 .iter()
263 .enumerate() 314 .enumerate()
264 { 315 {
316 let m_npub = maintainer_keys
317 .public_key()
318 .to_bech32()
319 .expect("Failed to get npub");
265 let announcement = EventBuilder::new( 320 let announcement = EventBuilder::new(
266 Kind::GitRepoAnnouncement, 321 Kind::GitRepoAnnouncement,
267 format!("Maintainer {} repository", idx + 1), 322 format!("Maintainer {} repository", idx + 1),
268 ) 323 )
269 .tags(vec![ 324 .tags(vec![
270 Tag::identifier(identifier), 325 Tag::identifier(identifier.as_str()),
271 Tag::custom( 326 Tag::custom(
272 TagKind::custom("clone"), 327 TagKind::custom("clone"),
273 vec![format!("https://{}/{}.git", relay_a.domain(), identifier)], 328 vec![format!(
329 "http://{}/{}/{}.git",
330 relay_a.domain(),
331 m_npub,
332 identifier
333 )],
274 ), 334 ),
275 Tag::custom(TagKind::custom("relays"), vec![relay_a.url().to_string()]), 335 Tag::custom(TagKind::custom("relays"), vec![relay_a.url().to_string()]),
276 ]) 336 ])
277 .sign_with_keys(maintainer_keys) 337 .sign_with_keys(maintainer_keys)
278 .unwrap(); 338 .unwrap();
339 send_to_relay(&relay_a, &announcement).await.unwrap();
340 // Use push_unique_git_data_to_relay so each maintainer gets a distinct commit
341 // hash. Identical hashes cause git to skip pack transfer when the object
342 // already exists on the server, leaving the announcement in purgatory.
343 let git_dir = push_unique_git_data_to_relay(
344 &relay_a,
345 maintainer_keys,
346 identifier,
347 &[&relay_a.domain()],
348 &m_npub,
349 )
350 .await;
351 git_dirs.push(git_dir);
352 }
353 println!("✓ Three maintainer announcements + git data pushed to relay_a");
279 354
280 client_a.send_event(&announcement).await.unwrap(); 355 // Confirm all three announcements are queryable on relay_a before starting relay_b.
356 // This eliminates the race between relay_a's DB writes and relay_b's initial negentropy sync.
357 for (name, keys) in [
358 ("maintainer1", &maintainer1_keys),
359 ("maintainer2", &maintainer2_keys),
360 ("maintainer3", &maintainer3_keys),
361 ] {
362 let filter = Filter::new()
363 .kind(Kind::GitRepoAnnouncement)
364 .author(keys.public_key())
365 .identifier(identifier);
366 let found =
367 wait_for_event_on_relay(relay_a.url(), filter, Duration::from_secs(10)).await;
368 assert!(found, "{} announcement should be in relay_a before starting relay_b", name);
281 } 369 }
282 println!("✓ Three maintainer announcements sent to relay_a"); 370 println!("✓ All three maintainer announcements confirmed in relay_a's DB");
283 371
284 // Step 2: Send owner announcement to relay_b (lists relay_a + all three maintainers) 372 // Step 2: Start relay_b with relay_a as bootstrap so its SyncManager connects immediately.
285 let client_b = TestClient::new(relay_b.url(), owner_keys.clone()) 373 // Because all three maintainer announcements are confirmed in relay_a's DB, relay_b's
286 .await 374 // initial negentropy sync will pick them all up and reject them (no owner announcement
287 .expect("Failed to connect to relay_b"); 375 // in relay_b's DB yet), storing them in the hot cache.
376 let relay_b = TestRelay::start_with_sync(Some(relay_a.url().to_string())).await;
377 println!("relay_b started at {}", relay_b.url());
378
379 // Give relay_b's SyncManager time to complete the initial negentropy sync with relay_a.
380 // The negentropy sync completes within ~200ms (NGIT_TEST=1 sets batch window to 200ms), but we
381 // allow extra time for slow CI environments.
382 tokio::time::sleep(Duration::from_secs(3)).await;
383 println!("✓ relay_b synced from relay_a (maintainer announcements should be in hot cache)");
384
385 // Step 3: Send owner announcement to relay_b → goes to purgatory.
386 let owner_npub = owner_keys
387 .public_key()
388 .to_bech32()
389 .expect("Failed to get npub");
288 390
289 let owner_announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "Owner's repository") 391 let owner_announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "Owner's repository")
290 .tags(vec![ 392 .tags(vec![
291 Tag::identifier(identifier), 393 Tag::identifier(identifier),
292 Tag::custom( 394 Tag::custom(
293 TagKind::custom("clone"), 395 TagKind::custom("clone"),
294 vec![format!("https://{}/{}.git", relay_b.domain(), identifier)], 396 vec![format!(
397 "http://{}/{}/{}.git",
398 relay_b.domain(),
399 owner_npub,
400 identifier
401 )],
295 ), 402 ),
296 Tag::custom( 403 Tag::custom(
297 TagKind::custom("relays"), 404 TagKind::custom("relays"),
@@ -309,13 +416,20 @@ async fn test_multiple_maintainers_all_reprocessed() {
309 .sign_with_keys(&owner_keys) 416 .sign_with_keys(&owner_keys)
310 .unwrap(); 417 .unwrap();
311 418
312 client_b.send_event(&owner_announcement).await.unwrap(); 419 send_to_relay(&relay_b, &owner_announcement).await.unwrap();
313 println!("✓ Owner announcement sent to relay_b"); 420 println!("✓ Owner announcement sent to relay_b (now in purgatory)");
314 421
315 // Step 3: Wait for sync and re-processing 422 // Step 4: Push owner git data to relay_b.
316 tokio::time::sleep(Duration::from_secs(3)).await; 423 // This promotes the owner announcement from purgatory and triggers hot-cache
424 // re-processing for all three maintainer announcements.
425 let _git_dir_owner =
426 push_git_data_to_relay(&relay_b, &owner_keys, identifier, &[&relay_b.domain()]).await;
427 println!("✓ Owner git data pushed to relay_b (hot-cache re-processing should fire)");
428
429 // Step 5: Wait briefly for async processing to complete.
430 tokio::time::sleep(Duration::from_secs(1)).await;
317 431
318 // Step 4: Verify all four announcements are in relay_b's database 432 // Step 6: Verify all four announcements are in relay_b's database.
319 for (name, keys) in [ 433 for (name, keys) in [
320 ("owner", &owner_keys), 434 ("owner", &owner_keys),
321 ("maintainer1", &maintainer1_keys), 435 ("maintainer1", &maintainer1_keys),
@@ -333,8 +447,6 @@ async fn test_multiple_maintainers_all_reprocessed() {
333 447
334 println!("✅ All three maintainer announcements re-processed successfully"); 448 println!("✅ All three maintainer announcements re-processed successfully");
335 449
336 client_a.disconnect().await;
337 client_b.disconnect().await;
338 relay_a.stop().await; 450 relay_a.stop().await;
339 relay_b.stop().await; 451 relay_b.stop().await;
340} 452}
@@ -342,10 +454,10 @@ async fn test_multiple_maintainers_all_reprocessed() {
342/// Test that invalid maintainer public keys don't cause panics 454/// Test that invalid maintainer public keys don't cause panics
343/// 455///
344/// Flow: 456/// Flow:
345/// 1. Maintainer announcement arrives → Rejected 457/// 1. Maintainer announcement arrives → Rejected (doesn't list our relay)
346/// 2. Owner announcement arrives with INVALID maintainer hex Should handle gracefully 458/// 2. Owner announcement + git push → accepted, with INVALID maintainer hex in maintainers tag
347/// 3. Owner announcement should still be accepted 459/// 3. Owner announcement should be accepted
348/// 4. Maintainer announcement should NOT be re-processed (invalid pubkey) 460/// 4. Maintainer announcement should NOT be re-processed (invalid pubkey can't be parsed)
349#[tokio::test] 461#[tokio::test]
350async fn test_invalid_maintainer_pubkey_handled_gracefully() { 462async fn test_invalid_maintainer_pubkey_handled_gracefully() {
351 let relay = TestRelay::start().await; 463 let relay = TestRelay::start().await;
@@ -382,13 +494,25 @@ async fn test_invalid_maintainer_pubkey_handled_gracefully() {
382 let _ = client.send_event(&maintainer_announcement).await; 494 let _ = client.send_event(&maintainer_announcement).await;
383 tokio::time::sleep(Duration::from_millis(200)).await; 495 tokio::time::sleep(Duration::from_millis(200)).await;
384 496
385 // Step 2: Send owner announcement with INVALID maintainer hex 497 // Step 2: Send owner announcement with INVALID maintainer hex, then push git data.
498 // The announcement goes to purgatory first; the git push promotes it.
499 // The invalid maintainer hex should be handled gracefully (no panic).
500 let owner_npub = owner_keys
501 .public_key()
502 .to_bech32()
503 .expect("Failed to get npub");
504
386 let owner_announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "Owner's repository") 505 let owner_announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "Owner's repository")
387 .tags(vec![ 506 .tags(vec![
388 Tag::identifier(identifier), 507 Tag::identifier(identifier),
389 Tag::custom( 508 Tag::custom(
390 TagKind::custom("clone"), 509 TagKind::custom("clone"),
391 vec![format!("https://{}/{}.git", relay.domain(), identifier)], 510 vec![format!(
511 "http://{}/{}/{}.git",
512 relay.domain(),
513 owner_npub,
514 identifier
515 )],
392 ), 516 ),
393 Tag::custom(TagKind::custom("relays"), vec![relay.url().to_string()]), 517 Tag::custom(TagKind::custom("relays"), vec![relay.url().to_string()]),
394 Tag::custom( 518 Tag::custom(
@@ -399,7 +523,9 @@ async fn test_invalid_maintainer_pubkey_handled_gracefully() {
399 .sign_with_keys(&owner_keys) 523 .sign_with_keys(&owner_keys)
400 .unwrap(); 524 .unwrap();
401 525
402 client.send_event(&owner_announcement).await.unwrap(); 526 send_to_relay(&relay, &owner_announcement).await.unwrap();
527 let _git_dir =
528 push_git_data_to_relay(&relay, &owner_keys, identifier, &[&relay.domain()]).await;
403 tokio::time::sleep(Duration::from_millis(500)).await; 529 tokio::time::sleep(Duration::from_millis(500)).await;
404 530
405 // Step 3: Verify owner announcement accepted, maintainer not re-processed 531 // Step 3: Verify owner announcement accepted, maintainer not re-processed
diff --git a/tests/sync/metrics.rs b/tests/sync/metrics.rs
index e8c75c7..e973bbb 100644
--- a/tests/sync/metrics.rs
+++ b/tests/sync/metrics.rs
@@ -16,8 +16,8 @@ use nostr_sdk::prelude::*;
16 16
17use crate::common::{ 17use crate::common::{
18 sync_helpers::{ 18 sync_helpers::{
19 create_repo_announcement, fetch_metrics, wait_for_sync_connection, MetricsTestHarness, 19 create_repo_announcement, fetch_metrics, setup_announcement_on_relay,
20 ParsedMetrics, TestClient, 20 wait_for_sync_connection, MetricsTestHarness, ParsedMetrics, TestClient,
21 }, 21 },
22 TestRelay, 22 TestRelay,
23}; 23};
@@ -224,16 +224,17 @@ async fn test_startup_sync_event_count() {
224 // 3. Create test keys 224 // 3. Create test keys
225 let keys = Keys::generate(); 225 let keys = Keys::generate();
226 226
227 // 4. Create an announcement that lists BOTH relays (required for discovery) 227 // 4. Set up announcement on SOURCE relay with git data
228 let announcement = create_repo_announcement( 228 // (purgatory requires git data before announcements are accepted)
229 &keys, 229 let repo_id = "test-repo-metrics";
230 &[&source_relay.domain(), &syncing_relay.domain()], 230 let domains = vec![source_relay.domain(), syncing_relay.domain()];
231 "test-repo-metrics", 231 let domain_refs: Vec<&str> = domains.iter().map(|s| s.as_str()).collect();
232 ); 232
233 let (announcement, _git_dir_source) =
234 setup_announcement_on_relay(&source_relay, &keys, &domain_refs, repo_id).await;
233 println!( 235 println!(
234 "Created announcement {} (kind {})", 236 "Announcement {} set up on source relay with git data",
235 announcement.id, 237 announcement.id
236 announcement.kind.as_u16()
237 ); 238 );
238 239
239 // 5. Build the repo coordinate for the 'a' tag in the patches 240 // 5. Build the repo coordinate for the 'a' tag in the patches
@@ -241,7 +242,7 @@ async fn test_startup_sync_event_count() {
241 "{}:{}:{}", 242 "{}:{}:{}",
242 Kind::GitRepoAnnouncement.as_u16(), 243 Kind::GitRepoAnnouncement.as_u16(),
243 keys.public_key().to_hex(), 244 keys.public_key().to_hex(),
244 "test-repo-metrics" 245 repo_id
245 ); 246 );
246 247
247 // 6. Create 3 patch events (Layer 2) that reference the announcement 248 // 6. Create 3 patch events (Layer 2) that reference the announcement
@@ -257,17 +258,11 @@ async fn test_startup_sync_event_count() {
257 .collect(); 258 .collect();
258 println!("Created {} patches", patches.len()); 259 println!("Created {} patches", patches.len());
259 260
260 // 7. Send announcement + patches to SOURCE relay ONLY 261 // 7. Send patches to SOURCE relay
261 let source_client = TestClient::new(source_relay.url(), keys.clone()) 262 let source_client = TestClient::new(source_relay.url(), keys.clone())
262 .await 263 .await
263 .expect("Failed to connect to source relay"); 264 .expect("Failed to connect to source relay");
264 265
265 source_client
266 .send_event(&announcement)
267 .await
268 .expect("Failed to send announcement to source");
269 println!("Announcement sent to source relay");
270
271 for patch in &patches { 266 for patch in &patches {
272 source_client 267 source_client
273 .send_event(patch) 268 .send_event(patch)
@@ -277,17 +272,10 @@ async fn test_startup_sync_event_count() {
277 println!("Patches sent to source relay"); 272 println!("Patches sent to source relay");
278 source_client.disconnect().await; 273 source_client.disconnect().await;
279 274
280 // 8. Send announcement to SYNCING relay (triggers discovery of source relay) 275 // 8. Set up announcement on SYNCING relay (triggers discovery of source relay)
281 let syncing_client = TestClient::new(syncing_relay.url(), keys.clone()) 276 let (_announcement_syncing, _git_dir_syncing) =
282 .await 277 setup_announcement_on_relay(&syncing_relay, &keys, &domain_refs, repo_id).await;
283 .expect("Failed to connect to syncing relay"); 278 println!("Announcement set up on syncing relay (triggers discovery of source)");
284
285 syncing_client
286 .send_event(&announcement)
287 .await
288 .expect("Failed to send announcement to syncing relay");
289 println!("Announcement sent to syncing relay (triggers discovery of source)");
290 syncing_client.disconnect().await;
291 279
292 // 9. Wait for discovery + sync to complete 280 // 9. Wait for discovery + sync to complete
293 println!("Waiting 5s for discovery and sync..."); 281 println!("Waiting 5s for discovery and sync...");
@@ -404,18 +392,35 @@ async fn test_connection_failure_increments_counter() {
404/// Test that live sync events are counted in metrics. 392/// Test that live sync events are counted in metrics.
405/// 393///
406/// This test validates that events received via live subscription 394/// This test validates that events received via live subscription
407/// (after sync connection is established) are counted separately 395/// (after sync connection is established) are counted in metrics.
408/// from startup/bootstrap events. 396/// Uses Layer 2 patch events (not announcements) to avoid purgatory,
397/// since Layer 2 events are accepted directly to the DB.
409#[tokio::test] 398#[tokio::test]
410async fn test_live_sync_event_count() { 399async fn test_live_sync_event_count() {
411 let mut harness = MetricsTestHarness::with_sources(1).await;
412
413 // Pre-allocate syncing relay port to include in announcements 400 // Pre-allocate syncing relay port to include in announcements
414 let sync_port = TestRelay::find_free_port(); 401 let sync_port = TestRelay::find_free_port();
415 let sync_domain = format!("127.0.0.1:{}", sync_port); 402 let sync_domain = format!("127.0.0.1:{}", sync_port);
416 403
404 // Start source relay
405 let source_relay = TestRelay::start().await;
406 println!("Source relay started at {}", source_relay.url());
407
408 // Set up announcement on source relay BEFORE starting syncing relay
409 // This allows discovery when syncing relay connects
410 let keys = Keys::generate();
411 let repo_id = "live-metrics-repo";
412 let domains = vec![source_relay.domain(), sync_domain.clone()];
413 let domain_refs: Vec<&str> = domains.iter().map(|s| s.as_str()).collect();
414
415 let (_announcement, _git_dir) =
416 setup_announcement_on_relay(&source_relay, &keys, &domain_refs, repo_id).await;
417 println!("Announcement set up on source relay with git data");
418
417 // Start syncing relay with pre-allocated port 419 // Start syncing relay with pre-allocated port
418 harness.start_syncing_relay_on_port(0, sync_port).await; 420 let syncing_relay =
421 TestRelay::start_on_port_with_options(sync_port, Some(source_relay.url().to_string()), false)
422 .await;
423 println!("Syncing relay started at {}", syncing_relay.url());
419 424
420 // Wait for sync connection to be fully established with EOSE received 425 // Wait for sync connection to be fully established with EOSE received
421 // This ensures we're in "live" mode before submitting test events 426 // This ensures we're in "live" mode before submitting test events
@@ -424,33 +429,61 @@ async fn test_live_sync_event_count() {
424 .await 429 .await
425 .expect("Sync connection should be established"); 430 .expect("Sync connection should be established");
426 431
427 // Additional small delay to ensure EOSE has been processed 432 // Additional delay to ensure purgatory promotion completes on syncing relay
428 tokio::time::sleep(Duration::from_millis(500)).await; 433 tokio::time::sleep(Duration::from_secs(4)).await;
429 434
430 // Now add events - these should be "live" not "startup" 435 // Now add Layer 2 patch events (not announcements) - these are accepted immediately
431 // Include BOTH domains so events are accepted by both relays 436 // (Layer 2 events are accepted directly to DB, no purgatory)
432 let keys = Keys::generate(); 437 let repo_coord_str = format!(
433 let events: Vec<_> = (0..2) 438 "{}:{}:{}",
434 .map(|i| { 439 Kind::GitRepoAnnouncement.as_u16(),
435 create_repo_announcement( 440 keys.public_key().to_hex(),
436 &keys, 441 repo_id
437 &[&harness.source_domain(0), &sync_domain], 442 );
438 &format!("live-{}", i), 443
439 ) 444 let patch1 = create_event_referencing_repo(
440 }) 445 &keys,
441 .collect(); 446 &repo_coord_str,
442 harness.submit_events(0, &events).await.unwrap(); 447 Kind::GitPatch.as_u16(),
448 "Live test patch 1",
449 );
450 let patch2 = create_event_referencing_repo(
451 &keys,
452 &repo_coord_str,
453 Kind::GitPatch.as_u16(),
454 "Live test patch 2",
455 );
456
457 // Send patches to source AFTER sync connection established (live mode)
458 let client = TestClient::new(source_relay.url(), keys.clone())
459 .await
460 .expect("Failed to connect to source");
461 client.send_event(&patch1).await.expect("Failed to send patch 1");
462 client.send_event(&patch2).await.expect("Failed to send patch 2");
463 client.disconnect().await;
464 println!("Two patches sent to source relay (live mode)");
443 465
444 // Wait for live events to be processed and metrics updated 466 // Wait for live events to be processed and metrics updated
445 tokio::time::sleep(Duration::from_secs(4)).await; 467 tokio::time::sleep(Duration::from_secs(4)).await;
446 let metrics = harness.get_metrics().await.unwrap(); 468
469 // Fetch metrics from syncing relay
470 let raw_metrics = fetch_metrics(&sync_url)
471 .await
472 .expect("Failed to fetch metrics");
473 let metrics = ParsedMetrics::parse(&raw_metrics);
447 474
448 let synced_count = metrics.events_synced_total(); 475 let synced_count = metrics.events_synced_total();
449 println!("Events synced total: {:?}", synced_count); 476 println!("Events synced total: {:?}", synced_count);
450 477
451 assert_eq!(synced_count, Some(2), "Should have 2 synced events"); 478 // Cleanup
479 syncing_relay.stop().await;
480 source_relay.stop().await;
452 481
453 harness.stop_all().await; 482 assert!(
483 synced_count.is_some() && synced_count.unwrap() >= 2,
484 "Should have synced at least 2 events, got {:?}",
485 synced_count
486 );
454} 487}
455 488
456/// Test that relay connected status is tracked in metrics. 489/// Test that relay connected status is tracked in metrics.
diff --git a/tests/sync/mod.rs b/tests/sync/mod.rs
index 400341f..70c6981 100644
--- a/tests/sync/mod.rs
+++ b/tests/sync/mod.rs
@@ -82,14 +82,12 @@
82//! **Example from `discovery.rs`:** 82//! **Example from `discovery.rs`:**
83//! ```rust 83//! ```rust
84//! #[tokio::test] 84//! #[tokio::test]
85//! async fn test_recursive_relay_discovery() { 85//! async fn test_discovers_layer3_via_layer2() {
86//! // Multi-relay orchestration 86//! // Multi-relay orchestration
87//! let relay1 = TestRelay::start().await; 87//! let relay_a = TestRelay::start().await;
88//! let relay2 = TestRelay::start().await; 88//! let relay_b = TestRelay::start_with_sync(None).await;
89//! let relay3 = TestRelay::start().await;
90//! 89//!
91//! // relay1 announces relay2, relay2 announces relay3 90//! // relay_b receives announcement listing relay_a, discovers and syncs from it
92//! // Verify relay1 discovers relay3 through chain
93//! } 91//! }
94//! ``` 92//! ```
95//! 93//!
diff --git a/tests/sync/tag_variations.rs b/tests/sync/tag_variations.rs
index 46b1203..021ad0e 100644
--- a/tests/sync/tag_variations.rs
+++ b/tests/sync/tag_variations.rs
@@ -55,30 +55,19 @@ async fn test_layer2_sync_with_lowercase_a_tag() {
55 55
56 let keys = Keys::generate(); 56 let keys = Keys::generate();
57 57
58 // 2. Create and send repository announcement to both relays 58 // 2. Create and send repository announcement to both relays with git data
59 // (purgatory requires git data before announcements are accepted)
59 let repo_id = "test-repo-tag-8a"; 60 let repo_id = "test-repo-tag-8a";
60 let announcement = 61 let domains = vec![relay_a.domain(), relay_b.domain()];
61 create_repo_announcement(&keys, &[&relay_a.domain(), &relay_b.domain()], repo_id); 62 let domain_refs: Vec<&str> = domains.iter().map(|s| s.as_str()).collect();
62 63
63 let client_a = TestClient::new(relay_a.url(), keys.clone()) 64 let (_announcement, _git_dir_a) =
64 .await 65 setup_announcement_on_relay(&relay_a, &keys, &domain_refs, repo_id).await;
65 .expect("Failed to connect to relay_a"); 66 println!("Announcement set up on relay_a with git data");
66
67 let client_b = TestClient::new(relay_b.url(), keys.clone())
68 .await
69 .expect("Failed to connect to relay_b");
70 67
71 client_a 68 let (_announcement_b, _git_dir_b) =
72 .send_event(&announcement) 69 setup_announcement_on_relay(&relay_b, &keys, &domain_refs, repo_id).await;
73 .await 70 println!("Announcement set up on relay_b with git data (triggers discovery)");
74 .expect("Failed to send announcement to relay_a");
75 println!("Announcement sent to relay_a");
76
77 client_b
78 .send_event(&announcement)
79 .await
80 .expect("Failed to send announcement to relay_b");
81 println!("Announcement sent to relay_b (triggers discovery)");
82 71
83 // 3. Wait for discovery 72 // 3. Wait for discovery
84 tokio::time::sleep(Duration::from_secs(1)).await; 73 tokio::time::sleep(Duration::from_secs(1)).await;
@@ -95,9 +84,10 @@ async fn test_layer2_sync_with_lowercase_a_tag() {
95 issue_id, 84 issue_id,
96 issue.kind.as_u16() 85 issue.kind.as_u16()
97 ); 86 );
98 for tag in issue.tags.iter() { 87
99 println!(" Tag: {:?}", tag.as_slice()); 88 let client_a = TestClient::new(relay_a.url(), keys.clone())
100 } 89 .await
90 .expect("Failed to connect to relay_a");
101 91
102 client_a 92 client_a
103 .send_event(&issue) 93 .send_event(&issue)
@@ -106,7 +96,6 @@ async fn test_layer2_sync_with_lowercase_a_tag() {
106 println!("Issue sent to relay_a"); 96 println!("Issue sent to relay_a");
107 97
108 client_a.disconnect().await; 98 client_a.disconnect().await;
109 client_b.disconnect().await;
110 99
111 // 5. Wait and verify event syncs to relay_b 100 // 5. Wait and verify event syncs to relay_b
112 let filter = Filter::new() 101 let filter = Filter::new()
@@ -154,30 +143,18 @@ async fn test_layer2_sync_with_uppercase_a_tag() {
154 143
155 let keys = Keys::generate(); 144 let keys = Keys::generate();
156 145
157 // 2. Create and send repository announcement to both relays 146 // 2. Create and send repository announcement to both relays with git data
158 let repo_id = "test-repo-tag-8b"; 147 let repo_id = "test-repo-tag-8b";
159 let announcement = 148 let domains = vec![relay_a.domain(), relay_b.domain()];
160 create_repo_announcement(&keys, &[&relay_a.domain(), &relay_b.domain()], repo_id); 149 let domain_refs: Vec<&str> = domains.iter().map(|s| s.as_str()).collect();
161
162 let client_a = TestClient::new(relay_a.url(), keys.clone())
163 .await
164 .expect("Failed to connect to relay_a");
165 150
166 let client_b = TestClient::new(relay_b.url(), keys.clone()) 151 let (_announcement, _git_dir_a) =
167 .await 152 setup_announcement_on_relay(&relay_a, &keys, &domain_refs, repo_id).await;
168 .expect("Failed to connect to relay_b"); 153 println!("Announcement set up on relay_a with git data");
169 154
170 client_a 155 let (_announcement_b, _git_dir_b) =
171 .send_event(&announcement) 156 setup_announcement_on_relay(&relay_b, &keys, &domain_refs, repo_id).await;
172 .await 157 println!("Announcement set up on relay_b with git data (triggers discovery)");
173 .expect("Failed to send announcement to relay_a");
174 println!("Announcement sent to relay_a");
175
176 client_b
177 .send_event(&announcement)
178 .await
179 .expect("Failed to send announcement to relay_b");
180 println!("Announcement sent to relay_b (triggers discovery)");
181 158
182 // 3. Wait for discovery 159 // 3. Wait for discovery
183 tokio::time::sleep(Duration::from_secs(1)).await; 160 tokio::time::sleep(Duration::from_secs(1)).await;
@@ -197,9 +174,10 @@ async fn test_layer2_sync_with_uppercase_a_tag() {
197 issue_id, 174 issue_id,
198 issue.kind.as_u16() 175 issue.kind.as_u16()
199 ); 176 );
200 for tag in issue.tags.iter() { 177
201 println!(" Tag: {:?}", tag.as_slice()); 178 let client_a = TestClient::new(relay_a.url(), keys.clone())
202 } 179 .await
180 .expect("Failed to connect to relay_a");
203 181
204 client_a 182 client_a
205 .send_event(&issue) 183 .send_event(&issue)
@@ -208,7 +186,6 @@ async fn test_layer2_sync_with_uppercase_a_tag() {
208 println!("Issue sent to relay_a"); 186 println!("Issue sent to relay_a");
209 187
210 client_a.disconnect().await; 188 client_a.disconnect().await;
211 client_b.disconnect().await;
212 189
213 // 5. Wait and verify event syncs to relay_b 190 // 5. Wait and verify event syncs to relay_b
214 let filter = Filter::new() 191 let filter = Filter::new()
@@ -255,30 +232,18 @@ async fn test_layer2_sync_with_q_tag() {
255 232
256 let keys = Keys::generate(); 233 let keys = Keys::generate();
257 234
258 // 2. Create and send repository announcement to both relays 235 // 2. Create and send repository announcement to both relays with git data
259 let repo_id = "test-repo-tag-8c"; 236 let repo_id = "test-repo-tag-8c";
260 let announcement = 237 let domains = vec![relay_a.domain(), relay_b.domain()];
261 create_repo_announcement(&keys, &[&relay_a.domain(), &relay_b.domain()], repo_id); 238 let domain_refs: Vec<&str> = domains.iter().map(|s| s.as_str()).collect();
262 239
263 let client_a = TestClient::new(relay_a.url(), keys.clone()) 240 let (_announcement, _git_dir_a) =
264 .await 241 setup_announcement_on_relay(&relay_a, &keys, &domain_refs, repo_id).await;
265 .expect("Failed to connect to relay_a"); 242 println!("Announcement set up on relay_a with git data");
266 243
267 let client_b = TestClient::new(relay_b.url(), keys.clone()) 244 let (_announcement_b, _git_dir_b) =
268 .await 245 setup_announcement_on_relay(&relay_b, &keys, &domain_refs, repo_id).await;
269 .expect("Failed to connect to relay_b"); 246 println!("Announcement set up on relay_b with git data (triggers discovery)");
270
271 client_a
272 .send_event(&announcement)
273 .await
274 .expect("Failed to send announcement to relay_a");
275 println!("Announcement sent to relay_a");
276
277 client_b
278 .send_event(&announcement)
279 .await
280 .expect("Failed to send announcement to relay_b");
281 println!("Announcement sent to relay_b (triggers discovery)");
282 247
283 // 3. Wait for discovery 248 // 3. Wait for discovery
284 tokio::time::sleep(Duration::from_secs(1)).await; 249 tokio::time::sleep(Duration::from_secs(1)).await;
@@ -294,9 +259,10 @@ async fn test_layer2_sync_with_q_tag() {
294 issue_id, 259 issue_id,
295 issue.kind.as_u16() 260 issue.kind.as_u16()
296 ); 261 );
297 for tag in issue.tags.iter() { 262
298 println!(" Tag: {:?}", tag.as_slice()); 263 let client_a = TestClient::new(relay_a.url(), keys.clone())
299 } 264 .await
265 .expect("Failed to connect to relay_a");
300 266
301 client_a 267 client_a
302 .send_event(&issue) 268 .send_event(&issue)
@@ -305,7 +271,6 @@ async fn test_layer2_sync_with_q_tag() {
305 println!("Issue sent to relay_a"); 271 println!("Issue sent to relay_a");
306 272
307 client_a.disconnect().await; 273 client_a.disconnect().await;
308 client_b.disconnect().await;
309 274
310 // 5. Wait and verify event syncs to relay_b 275 // 5. Wait and verify event syncs to relay_b
311 let filter = Filter::new() 276 let filter = Filter::new()
@@ -362,30 +327,18 @@ async fn test_layer3_sync_with_lowercase_e_tag() {
362 327
363 let keys = Keys::generate(); 328 let keys = Keys::generate();
364 329
365 // 2. Create and send repository announcement to both relays 330 // 2. Create and send repository announcement to both relays with git data
366 let repo_id = "test-repo-tag-9a"; 331 let repo_id = "test-repo-tag-9a";
367 let announcement = 332 let domains = vec![relay_a.domain(), relay_b.domain()];
368 create_repo_announcement(&keys, &[&relay_a.domain(), &relay_b.domain()], repo_id); 333 let domain_refs: Vec<&str> = domains.iter().map(|s| s.as_str()).collect();
369 334
370 let client_a = TestClient::new(relay_a.url(), keys.clone()) 335 let (_announcement, _git_dir_a) =
371 .await 336 setup_announcement_on_relay(&relay_a, &keys, &domain_refs, repo_id).await;
372 .expect("Failed to connect to relay_a"); 337 println!("Announcement set up on relay_a with git data");
373
374 let client_b = TestClient::new(relay_b.url(), keys.clone())
375 .await
376 .expect("Failed to connect to relay_b");
377 338
378 client_a 339 let (_announcement_b, _git_dir_b) =
379 .send_event(&announcement) 340 setup_announcement_on_relay(&relay_b, &keys, &domain_refs, repo_id).await;
380 .await 341 println!("Announcement set up on relay_b with git data (triggers discovery)");
381 .expect("Failed to send announcement to relay_a");
382 println!("Announcement sent to relay_a");
383
384 client_b
385 .send_event(&announcement)
386 .await
387 .expect("Failed to send announcement to relay_b");
388 println!("Announcement sent to relay_b (triggers discovery)");
389 342
390 // 3. Wait for discovery 343 // 3. Wait for discovery
391 tokio::time::sleep(Duration::from_secs(1)).await; 344 tokio::time::sleep(Duration::from_secs(1)).await;
@@ -396,6 +349,10 @@ async fn test_layer3_sync_with_lowercase_e_tag() {
396 .expect("Failed to create issue"); 349 .expect("Failed to create issue");
397 let issue_id = issue.id; 350 let issue_id = issue.id;
398 351
352 let client_a = TestClient::new(relay_a.url(), keys.clone())
353 .await
354 .expect("Failed to connect to relay_a");
355
399 client_a 356 client_a
400 .send_event(&issue) 357 .send_event(&issue)
401 .await 358 .await
@@ -410,11 +367,6 @@ async fn test_layer3_sync_with_lowercase_e_tag() {
410 assert!(issue_synced, "Layer 2 issue should sync first"); 367 assert!(issue_synced, "Layer 2 issue should sync first");
411 368
412 // Wait for Layer 3 subscriptions to be established 369 // Wait for Layer 3 subscriptions to be established
413 // After issue syncs, relay_b's SelfSubscriber needs time to:
414 // 1. Receive the synced issue via notify_event broadcast
415 // 2. Batch timer to tick (up to 200ms in tests)
416 // 3. Process batch and create Layer 3 filters
417 // 4. Subscribe to relay_a with Layer 3 filters
418 tokio::time::sleep(Duration::from_millis(500)).await; 370 tokio::time::sleep(Duration::from_millis(500)).await;
419 371
420 // 6. Create and send Layer 3 reply with lowercase 'e' tag (kind 1) 372 // 6. Create and send Layer 3 reply with lowercase 'e' tag (kind 1)
@@ -427,9 +379,6 @@ async fn test_layer3_sync_with_lowercase_e_tag() {
427 reply_id, 379 reply_id,
428 reply.kind.as_u16() 380 reply.kind.as_u16()
429 ); 381 );
430 for tag in reply.tags.iter() {
431 println!(" Tag: {:?}", tag.as_slice());
432 }
433 382
434 client_a 383 client_a
435 .send_event(&reply) 384 .send_event(&reply)
@@ -438,7 +387,6 @@ async fn test_layer3_sync_with_lowercase_e_tag() {
438 println!("Layer 3 reply {} sent to relay_a", reply_id); 387 println!("Layer 3 reply {} sent to relay_a", reply_id);
439 388
440 client_a.disconnect().await; 389 client_a.disconnect().await;
441 client_b.disconnect().await;
442 390
443 // 7. Wait and verify reply syncs to relay_b 391 // 7. Wait and verify reply syncs to relay_b
444 let reply_filter = Filter::new() 392 let reply_filter = Filter::new()
@@ -486,30 +434,18 @@ async fn test_layer3_sync_with_uppercase_e_tag() {
486 434
487 let keys = Keys::generate(); 435 let keys = Keys::generate();
488 436
489 // 2. Create and send repository announcement to both relays 437 // 2. Create and send repository announcement to both relays with git data
490 let repo_id = "test-repo-tag-9b"; 438 let repo_id = "test-repo-tag-9b";
491 let announcement = 439 let domains = vec![relay_a.domain(), relay_b.domain()];
492 create_repo_announcement(&keys, &[&relay_a.domain(), &relay_b.domain()], repo_id); 440 let domain_refs: Vec<&str> = domains.iter().map(|s| s.as_str()).collect();
493
494 let client_a = TestClient::new(relay_a.url(), keys.clone())
495 .await
496 .expect("Failed to connect to relay_a");
497 441
498 let client_b = TestClient::new(relay_b.url(), keys.clone()) 442 let (_announcement, _git_dir_a) =
499 .await 443 setup_announcement_on_relay(&relay_a, &keys, &domain_refs, repo_id).await;
500 .expect("Failed to connect to relay_b"); 444 println!("Announcement set up on relay_a with git data");
501 445
502 client_a 446 let (_announcement_b, _git_dir_b) =
503 .send_event(&announcement) 447 setup_announcement_on_relay(&relay_b, &keys, &domain_refs, repo_id).await;
504 .await 448 println!("Announcement set up on relay_b with git data (triggers discovery)");
505 .expect("Failed to send announcement to relay_a");
506 println!("Announcement sent to relay_a");
507
508 client_b
509 .send_event(&announcement)
510 .await
511 .expect("Failed to send announcement to relay_b");
512 println!("Announcement sent to relay_b (triggers discovery)");
513 449
514 // 3. Wait for discovery 450 // 3. Wait for discovery
515 tokio::time::sleep(Duration::from_secs(1)).await; 451 tokio::time::sleep(Duration::from_secs(1)).await;
@@ -520,6 +456,10 @@ async fn test_layer3_sync_with_uppercase_e_tag() {
520 .expect("Failed to create issue"); 456 .expect("Failed to create issue");
521 let issue_id = issue.id; 457 let issue_id = issue.id;
522 458
459 let client_a = TestClient::new(relay_a.url(), keys.clone())
460 .await
461 .expect("Failed to connect to relay_a");
462
523 client_a 463 client_a
524 .send_event(&issue) 464 .send_event(&issue)
525 .await 465 .await
@@ -534,11 +474,6 @@ async fn test_layer3_sync_with_uppercase_e_tag() {
534 assert!(issue_synced, "Layer 2 issue should sync first"); 474 assert!(issue_synced, "Layer 2 issue should sync first");
535 475
536 // Wait for Layer 3 subscriptions to be established 476 // Wait for Layer 3 subscriptions to be established
537 // After issue syncs, relay_b's SelfSubscriber needs time to:
538 // 1. Receive the synced issue via notify_event broadcast
539 // 2. Batch timer to tick (up to 200ms in tests)
540 // 3. Process batch and create Layer 3 filters
541 // 4. Subscribe to relay_a with Layer 3 filters
542 tokio::time::sleep(Duration::from_millis(500)).await; 477 tokio::time::sleep(Duration::from_millis(500)).await;
543 478
544 // 6. Create and send Layer 3 comment with uppercase 'E' tag (kind 1111) 479 // 6. Create and send Layer 3 comment with uppercase 'E' tag (kind 1111)
@@ -552,9 +487,6 @@ async fn test_layer3_sync_with_uppercase_e_tag() {
552 comment_id, 487 comment_id,
553 comment.kind.as_u16() 488 comment.kind.as_u16()
554 ); 489 );
555 for tag in comment.tags.iter() {
556 println!(" Tag: {:?}", tag.as_slice());
557 }
558 490
559 client_a 491 client_a
560 .send_event(&comment) 492 .send_event(&comment)
@@ -563,7 +495,6 @@ async fn test_layer3_sync_with_uppercase_e_tag() {
563 println!("Layer 3 comment {} sent to relay_a", comment_id); 495 println!("Layer 3 comment {} sent to relay_a", comment_id);
564 496
565 client_a.disconnect().await; 497 client_a.disconnect().await;
566 client_b.disconnect().await;
567 498
568 // 7. Wait and verify comment syncs to relay_b 499 // 7. Wait and verify comment syncs to relay_b
569 let comment_filter = Filter::new() 500 let comment_filter = Filter::new()
@@ -614,30 +545,18 @@ async fn test_layer3_sync_with_q_tag() {
614 545
615 let keys = Keys::generate(); 546 let keys = Keys::generate();
616 547
617 // 2. Create and send repository announcement to both relays 548 // 2. Create and send repository announcement to both relays with git data
618 let repo_id = "test-repo-tag-9c"; 549 let repo_id = "test-repo-tag-9c";
619 let announcement = 550 let domains = vec![relay_a.domain(), relay_b.domain()];
620 create_repo_announcement(&keys, &[&relay_a.domain(), &relay_b.domain()], repo_id); 551 let domain_refs: Vec<&str> = domains.iter().map(|s| s.as_str()).collect();
621 552
622 let client_a = TestClient::new(relay_a.url(), keys.clone()) 553 let (_announcement, _git_dir_a) =
623 .await 554 setup_announcement_on_relay(&relay_a, &keys, &domain_refs, repo_id).await;
624 .expect("Failed to connect to relay_a"); 555 println!("Announcement set up on relay_a with git data");
625 556
626 let client_b = TestClient::new(relay_b.url(), keys.clone()) 557 let (_announcement_b, _git_dir_b) =
627 .await 558 setup_announcement_on_relay(&relay_b, &keys, &domain_refs, repo_id).await;
628 .expect("Failed to connect to relay_b"); 559 println!("Announcement set up on relay_b with git data (triggers discovery)");
629
630 client_a
631 .send_event(&announcement)
632 .await
633 .expect("Failed to send announcement to relay_a");
634 println!("Announcement sent to relay_a");
635
636 client_b
637 .send_event(&announcement)
638 .await
639 .expect("Failed to send announcement to relay_b");
640 println!("Announcement sent to relay_b (triggers discovery)");
641 560
642 // 3. Wait for discovery 561 // 3. Wait for discovery
643 tokio::time::sleep(Duration::from_secs(1)).await; 562 tokio::time::sleep(Duration::from_secs(1)).await;
@@ -648,6 +567,10 @@ async fn test_layer3_sync_with_q_tag() {
648 .expect("Failed to create issue"); 567 .expect("Failed to create issue");
649 let issue_id = issue.id; 568 let issue_id = issue.id;
650 569
570 let client_a = TestClient::new(relay_a.url(), keys.clone())
571 .await
572 .expect("Failed to connect to relay_a");
573
651 client_a 574 client_a
652 .send_event(&issue) 575 .send_event(&issue)
653 .await 576 .await
@@ -662,11 +585,6 @@ async fn test_layer3_sync_with_q_tag() {
662 assert!(issue_synced, "Layer 2 issue should sync first"); 585 assert!(issue_synced, "Layer 2 issue should sync first");
663 586
664 // Wait for Layer 3 subscriptions to be established 587 // Wait for Layer 3 subscriptions to be established
665 // After issue syncs, relay_b's SelfSubscriber needs time to:
666 // 1. Receive the synced issue via notify_event broadcast
667 // 2. Batch timer to tick (up to 200ms in tests)
668 // 3. Process batch and create Layer 3 filters
669 // 4. Subscribe to relay_a with Layer 3 filters
670 tokio::time::sleep(Duration::from_millis(500)).await; 588 tokio::time::sleep(Duration::from_millis(500)).await;
671 589
672 // 6. Create and send Layer 3 quote with 'q' tag (kind 1) 590 // 6. Create and send Layer 3 quote with 'q' tag (kind 1)
@@ -679,9 +597,6 @@ async fn test_layer3_sync_with_q_tag() {
679 quote_id, 597 quote_id,
680 quote.kind.as_u16() 598 quote.kind.as_u16()
681 ); 599 );
682 for tag in quote.tags.iter() {
683 println!(" Tag: {:?}", tag.as_slice());
684 }
685 600
686 client_a 601 client_a
687 .send_event(&quote) 602 .send_event(&quote)
@@ -690,7 +605,6 @@ async fn test_layer3_sync_with_q_tag() {
690 println!("Layer 3 quote {} sent to relay_a", quote_id); 605 println!("Layer 3 quote {} sent to relay_a", quote_id);
691 606
692 client_a.disconnect().await; 607 client_a.disconnect().await;
693 client_b.disconnect().await;
694 608
695 // 7. Wait and verify quote syncs to relay_b 609 // 7. Wait and verify quote syncs to relay_b
696 let quote_filter = Filter::new() 610 let quote_filter = Filter::new()