From d688526c772fceb1a975de8af9e3a7e8e98000e5 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Sat, 12 Sep 2026 16:12:21 +0000 Subject: [PATCH 1/4] test(sync): force a filter refusal under either startup ordering The adaptive regrouping test assumed one request would carry more than three filters. Minimum-churn startup can correctly split core and auxiliary coverage into two-filter requests, so the required refusal never occurred. Make the fixture accept only one filter per request. Both valid startup orderings must then exercise rejection and complete live regrouping, with the existing bounded condition and post-regrouping event assertion intact. No production request packing or peer limit handling changes. Validation: the old fixture fails in isolation without a filter refusal; the stricter fixture passes and delivers the later q-tagged issue. (cherry picked from commit db05007ddb9ce85aa2a4a56cd3462e167c49e6fc) Assisted-by: Codex (GPT-6) --- tests/sync/live_sync.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/sync/live_sync.rs b/tests/sync/live_sync.rs index 36dd2a3..4961b6c 100644 --- a/tests/sync/live_sync.rs +++ b/tests/sync/live_sync.rs @@ -205,7 +205,9 @@ async fn test_live_sync_batches_repo_filters_below_source_req_limit() { #[tokio::test] async fn live_sync_regroups_after_filter_count_refusal() { - let source = MockRelay::start_with_max_filters(3).await; + // Minimum-churn startup can split core and auxiliary coverage into two + // filters each. A one-filter cap forces regrouping under either ordering. + let source = MockRelay::start_with_max_filters(1).await; let syncing = TestRelay::start_with_sync(None).await; let keys = Keys::generate(); let repo_id = "adaptive-filter-count"; From fc99132f712cc73900656adac0b2e4627468779c Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Mon, 14 Sep 2026 11:41:31 +0000 Subject: [PATCH 2/4] test(sync): observe source connection before asserting its gauge HTTP startup can finish while the sync manager still reports its initial false connection gauge. The test sampled it immediately, so removing the fixture grace sleep exposed a scheduler-dependent assertion. Wait for the specific source's connected gauge with a bounded deadline, then retain the existing disconnect and retirement assertions. An aggregate connection count could be satisfied by another relay. Production metrics and connection behavior are deliberately unchanged. Validation: source inspection, rustfmt and whitespace checks pass. Runtime execution is deferred to CI/host as requested; no Cargo tests run in the VM. Assisted-by: Codex (GPT-6) --- tests/sync/metrics.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/sync/metrics.rs b/tests/sync/metrics.rs index cbfb9be..5bfe3d5 100644 --- a/tests/sync/metrics.rs +++ b/tests/sync/metrics.rs @@ -523,8 +523,19 @@ async fn test_relay_connected_status() { let source_url = harness.source_url(0).to_string(); - // Check connected status - let metrics = harness.get_metrics().await.unwrap(); + // HTTP readiness does not imply that outbound sync has connected yet. + // Observe this source's gauge, not an aggregate that another relay can satisfy. + let metrics = tokio::time::timeout(Duration::from_secs(30), async { + loop { + let metrics = harness.get_metrics().await.unwrap(); + if metrics.relay_connected(&source_url) == Some(true) { + return metrics; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + }) + .await + .expect("source relay should become connected within the deadline"); println!("Checking connection status for {}", source_url); From 2a13fa7d7a790e1f726c6589a4d678c87fe2888d Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Mon, 14 Sep 2026 14:39:24 +0000 Subject: [PATCH 3/4] test(sync): report evidence when setup visibility fails CI passed the readiness and filter-refusal corrections but failed a descendant test inside the shared announcement setup helper. The assertion alone cannot identify the relay or distinguish replacement from promotion or query-delivery failure. On failure only, report the expected event identity and timestamp, query the same replaceable address under a bounded deadline, and print the relay log tail. Keep both exact-ID assertions and their original success deadlines. This adds diagnostics rather than claiming an unconfirmed production fix. Validation: rustfmt and whitespace checks pass. CI/runtime execution remains pending; no Cargo tests or heavyweight builds were run in the VM. Assisted-by: Codex (GPT-6) --- tests/common/sync_helpers.rs | 82 ++++++++++++++++++++++++++++-------- 1 file changed, 65 insertions(+), 17 deletions(-) diff --git a/tests/common/sync_helpers.rs b/tests/common/sync_helpers.rs index 75e9ae9..f448f2c 100644 --- a/tests/common/sync_helpers.rs +++ b/tests/common/sync_helpers.rs @@ -1431,29 +1431,77 @@ pub async fn setup_announcement_on_relay( push_to_relay(git_temp_dir.path(), &relay.domain(), &npub, identifier) .expect("Failed to push git data to relay"); - assert!( - wait_for_event_on_relay( + for (label, event) in [("state", &state_event), ("announcement", &announcement)] { + let visible = wait_for_event_on_relay( relay.url(), - Filter::new().id(state_event.id), - Duration::from_secs(10) + Filter::new().id(event.id), + Duration::from_secs(10), ) - .await, - "pushed state event must leave purgatory" - ); - - assert!( - wait_for_event_on_relay( - relay.url(), - Filter::new().id(announcement.id), - Duration::from_secs(10) - ) - .await, - "pushed announcement must leave purgatory" - ); + .await; + if !visible { + report_setup_visibility_failure(relay, keys, identifier, event).await; + } + assert!(visible, "pushed {label} must leave purgatory"); + } (announcement, git_temp_dir) } +/// Diagnose a failed exact-ID check without extending its success deadline. +async fn report_setup_visibility_failure( + relay: &TestRelay, + keys: &Keys, + identifier: &str, + expected: &Event, +) { + eprintln!( + "setup visibility failure: relay={} kind={} author={} identifier={} expected_id={} created_at={}", + relay.url(), expected.kind.as_u16(), expected.pubkey, identifier, + expected.id, expected.created_at + ); + // A different event at the same replaceable address distinguishes a lost + // replacement race from an event that was never promoted or served. + let diagnostic = tokio::time::timeout(Duration::from_secs(5), async { + let client = TestClient::new(relay.url(), keys.clone()).await?; + let result = client + .client + .fetch_events( + Filter::new() + .kind(expected.kind) + .author(expected.pubkey) + .identifier(identifier), + ) + .timeout(Duration::from_secs(2)) + .await; + client.disconnect().await; + let events = result.map_err(|error| error.to_string())?; + for event in events.iter() { + eprintln!( + "stored candidate: id={} created_at={}", + event.id, event.created_at + ); + } + eprintln!("stored candidate count: {}", events.len()); + Ok::<(), String>(()) + }) + .await; + match diagnostic { + Ok(Ok(())) => {} + Ok(Err(error)) => eprintln!("setup diagnostic query failed: {error}"), + Err(_) => eprintln!("setup diagnostic query timed out"), + } + match std::fs::read_to_string(relay.log_path()) { + Ok(log) => { + let tail = log.lines().rev().take(80).collect::>(); + eprintln!("setup relay log tail:"); + for line in tail.into_iter().rev() { + eprintln!("{line}"); + } + } + Err(error) => eprintln!("setup relay log unavailable: {error}"), + } +} + /// Unified sync test helper that automatically determines sync mode. /// /// This function sets up a complete sync test environment by determining whether From d47b9929d59f5fac51ef0d6f531df0dc43e42592 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Tue, 15 Sep 2026 07:45:52 +0000 Subject: [PATCH 4/4] test(sync): reuse repository identity across relay fixtures Paired setup calls constructed separate announcements, state events and Git commits for the same owner/identifier. Discovery could exchange those replaceable events while setup waited for an exact ID, making fixture identity depend on timestamp and event-ID ordering. Prepare one RepositoryFixture and install its unchanged signed events and Git data on each relay. Migrate all sixteen paired scenarios in descendant, discovery, live-sync, metrics and tag-variation tests. Historic tests retain the target listener reservation before seeding the source, so the original announcement can list both endpoints without starting target sync early. Keep exact-ID visibility checks, their deadlines and failure diagnostics. Add a wire-level regression checking both event IDs and remote Git refs after repeated installation. Production replacement policy, dependencies, intentional revision tests and concurrency remain unchanged. Validation: rustfmt and whitespace checks pass; paired setup callers audited. The new regression and migrated scenarios await CI execution, per the request to keep Cargo tests on CI/host. The preceding diagnostic CI revision stopped at a GitHub archive HTTP 504 before Rust tests and gave no further evidence about the original setup failure. Assisted-by: Codex (GPT-6) --- docs/how-to/test-fixtures.md | 18 +++ tests/common/sync_helpers.rs | 197 +++++++++++++++++-------------- tests/sync.rs | 1 + tests/sync/descendant_sync.rs | 55 +++++---- tests/sync/discovery.rs | 10 +- tests/sync/live_sync.rs | 15 +-- tests/sync/metrics.rs | 5 +- tests/sync/repository_fixture.rs | 104 ++++++++++++++++ tests/sync/tag_variations.rs | 30 ++--- 9 files changed, 283 insertions(+), 152 deletions(-) create mode 100644 tests/sync/repository_fixture.rs diff --git a/docs/how-to/test-fixtures.md b/docs/how-to/test-fixtures.md index 1f693d3..adec5f8 100644 --- a/docs/how-to/test-fixtures.md +++ b/docs/how-to/test-fixtures.md @@ -27,6 +27,24 @@ Connection tasks belong to their fixture's accept loop. Stopping the fixture cancels and joins them; dropping it cancels the owner. Git fixture subprocesses are cancelled with their request, and all three pipes are driven concurrently. +## Shared repository identity + +`setup_announcement_on_relay` returns an announcement and a `RepositoryFixture`. +Prepare it once for each logical repository, then call `fixture.install(&relay)` +for every other relay. It reuses the signed announcement, signed state event, +and Git commit. Repeating setup independently can create competing replaceable +events at the same owner/identifier and different Git histories. + +List all participating domains when preparing the fixture. If historic tests +must seed a source before starting the syncing relay, reserve the target's +listener first and transfer it into the target after seeding. Do not use sleeps +or fabricated future timestamps to order copies of the same fixture. Tests of +actual repository revisions should construct those revisions explicitly. + +The sync regression `reinstalling_shared_repository_preserves_event_ids_and_git_refs` +checks exact announcement/state IDs and remote Git refs across installation and +reinstallation on two relays. + ## Time and streaming assertions Age private cache timestamps explicitly and bracket wall-clock timestamps diff --git a/tests/common/sync_helpers.rs b/tests/common/sync_helpers.rs index f448f2c..55e4fb0 100644 --- a/tests/common/sync_helpers.rs +++ b/tests/common/sync_helpers.rs @@ -1351,100 +1351,121 @@ pub async fn push_unique_git_data_to_relay( git_temp_dir } -/// Set up a repository announcement on a relay with git data so it passes purgatory. +/// One repository's immutable Git data and signed events for all test relays. /// -/// With the announcement purgatory feature, announcements (kind 30617) require git -/// data before they are promoted to the relay's main DB. This helper: -/// -/// 1. Creates a local git repo with a commit -/// 2. Builds an announcement and state event (kind 30618) pointing to the relay -/// 3. Sends both to the relay (they go to purgatory) -/// 4. Git pushes to the relay → releases both from purgatory immediately -/// 5. Returns the announcement event and temp dir (keep alive for test duration) -/// -/// # Arguments -/// * `relay` - The relay to set up the announcement on -/// * `keys` - Keys to sign the announcement with (repo owner) -/// * `domains` - All domains that should be listed in the announcement (including relay.domain()) -/// * `identifier` - Repository identifier (d-tag) -/// -/// # Returns -/// `(Event, tempfile::TempDir)` - The announcement event and temp dir. -/// The temp dir MUST be kept alive for the duration of the test. +/// Preparing separate copies can mint competing replaceable events for the same +/// owner/identifier and different Git commits. Prepare once, list every endpoint +/// (reserve its listener first if necessary), and install this fixture on each +/// relay. Keep it alive until the test finishes. +pub struct RepositoryFixture { + git_dir: tempfile::TempDir, + announcement: Event, + state_event: Event, + keys: Keys, + identifier: String, +} + +impl RepositoryFixture { + pub fn new(keys: &Keys, domains: &[&str], identifier: &str) -> Self { + use super::purgatory_helpers::{ + create_state_event, create_test_repo_with_commit, CommitVariant, + }; + + let npub = keys + .public_key() + .to_bech32() + .expect("Failed to convert public key to npub"); + + // Create local git repo with a commit + let git_temp_dir = tempfile::tempdir().expect("Failed to create temp dir for git repo"); + let commit_hash = + create_test_repo_with_commit(git_temp_dir.path(), CommitVariant::StateTest) + .expect("Failed to create test git repo"); + + // Build clone URLs and relay URLs from domains + let clone_urls: Vec = domains + .iter() + .map(|d| format!("http://{}/{}/{}.git", d, npub, identifier)) + .collect(); + let relay_urls: Vec = domains.iter().map(|d| format!("ws://{}", d)).collect(); + + // Build announcement event (lists ALL domains for relay discovery) + let announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "Repository state") + .tags(vec![ + Tag::identifier(identifier), + Tag::custom("clone", clone_urls.clone()), + Tag::custom("relays", relay_urls.clone()), + ]) + .finalize(keys) + .expect("Failed to sign repo announcement"); + + // Build state event with all domains' clone URLs + let state_event = create_state_event( + keys, + identifier, + &[("main", &commit_hash)], + &[], + &clone_urls.iter().map(|s| s.as_str()).collect::>(), + &relay_urls.iter().map(|s| s.as_str()).collect::>(), + ) + .expect("Failed to create state event"); + + Self { + git_dir: git_temp_dir, + announcement, + state_event, + keys: keys.clone(), + identifier: identifier.to_string(), + } + } + + pub fn path(&self) -> &std::path::Path { + self.git_dir.path() + } + + /// Publish the same events and push the same commit to another relay. + pub async fn install(&self, relay: &TestRelay) { + use super::purgatory_helpers::push_to_relay; + + send_to_relay(relay, &self.announcement) + .await + .expect("Failed to send announcement"); + send_to_relay(relay, &self.state_event) + .await + .expect("Failed to send state event"); + let npub = self.keys.public_key().to_bech32().expect("owner npub"); + push_to_relay(self.path(), &relay.domain(), &npub, &self.identifier) + .expect("Failed to push git data to relay"); + + for (label, event) in [ + ("state", &self.state_event), + ("announcement", &self.announcement), + ] { + let visible = wait_for_event_on_relay( + relay.url(), + Filter::new().id(event.id), + Duration::from_secs(10), + ) + .await; + if !visible { + report_setup_visibility_failure(relay, &self.keys, &self.identifier, event).await; + } + assert!(visible, "pushed {label} must leave purgatory"); + } + } +} + +/// Prepare and install a repository on its first relay. For another relay, +/// call `install` on the returned fixture instead of preparing a new copy. pub async fn setup_announcement_on_relay( relay: &TestRelay, keys: &Keys, domains: &[&str], identifier: &str, -) -> (Event, tempfile::TempDir) { - use super::purgatory_helpers::{ - create_state_event, create_test_repo_with_commit, push_to_relay, CommitVariant, - }; - - let npub = keys - .public_key() - .to_bech32() - .expect("Failed to convert public key to npub"); - - // Create local git repo with a commit - let git_temp_dir = tempfile::tempdir().expect("Failed to create temp dir for git repo"); - let commit_hash = create_test_repo_with_commit(git_temp_dir.path(), CommitVariant::StateTest) - .expect("Failed to create test git repo"); - - // Build clone URLs and relay URLs from domains - let clone_urls: Vec = domains - .iter() - .map(|d| format!("http://{}/{}/{}.git", d, npub, identifier)) - .collect(); - let relay_urls: Vec = domains.iter().map(|d| format!("ws://{}", d)).collect(); - - // Build announcement event (lists ALL domains for relay discovery) - let announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "Repository state") - .tags(vec![ - Tag::identifier(identifier), - Tag::custom("clone", clone_urls.clone()), - Tag::custom("relays", relay_urls.clone()), - ]) - .finalize(keys) - .expect("Failed to sign repo announcement"); - - // Build state event with all domains' clone URLs - let state_event = create_state_event( - keys, - identifier, - &[("main", &commit_hash)], - &[], - &clone_urls.iter().map(|s| s.as_str()).collect::>(), - &relay_urls.iter().map(|s| s.as_str()).collect::>(), - ) - .expect("Failed to create state event"); - - // Send announcement and state event to relay (both go to purgatory) - send_to_relay(relay, &announcement) - .await - .expect("Failed to send announcement"); - send_to_relay(relay, &state_event) - .await - .expect("Failed to send state event"); - - // Git push to relay → releases both from purgatory - push_to_relay(git_temp_dir.path(), &relay.domain(), &npub, identifier) - .expect("Failed to push git data to relay"); - - for (label, event) in [("state", &state_event), ("announcement", &announcement)] { - let visible = wait_for_event_on_relay( - relay.url(), - Filter::new().id(event.id), - Duration::from_secs(10), - ) - .await; - if !visible { - report_setup_visibility_failure(relay, keys, identifier, event).await; - } - assert!(visible, "pushed {label} must leave purgatory"); - } - - (announcement, git_temp_dir) +) -> (Event, RepositoryFixture) { + let fixture = RepositoryFixture::new(keys, domains, identifier); + fixture.install(relay).await; + (fixture.announcement.clone(), fixture) } /// Diagnose a failed exact-ID check without extending its success deadline. diff --git a/tests/sync.rs b/tests/sync.rs index 9ce8a73..bd9b66d 100644 --- a/tests/sync.rs +++ b/tests/sync.rs @@ -46,6 +46,7 @@ mod sync { pub mod proactive_sync_plus; pub mod purgatory_fetch; pub mod reconnect_backoff; + pub mod repository_fixture; pub mod req_concurrency; pub mod stale_connect_result; pub mod tag_variations; diff --git a/tests/sync/descendant_sync.rs b/tests/sync/descendant_sync.rs index 672f55b..9a4d8ed 100644 --- a/tests/sync/descendant_sync.rs +++ b/tests/sync/descendant_sync.rs @@ -31,12 +31,14 @@ async fn historic_recursive_cap_is_enforced_and_reconstructed_after_restart() { let source = TestRelay::start_with_relay_max_subscriptions(4).await; let keys = Keys::generate(); let repo_id = "restart-safe-recursive-cap"; - let source_domains = [source.domain()]; + let syncing_reservation = reserve_port(); + let syncing_domain = format!("127.0.0.1:{}", syncing_reservation.port()); + let source_domains = [source.domain(), syncing_domain]; let source_refs = source_domains .iter() .map(String::as_str) .collect::>(); - let (_announcement, _source_git) = + let (_announcement, repository) = setup_announcement_on_relay(&source, &keys, &source_refs, repo_id).await; let source_client = TestClient::new(source.url(), keys.clone()) .await @@ -64,7 +66,7 @@ async fn historic_recursive_cap_is_enforced_and_reconstructed_after_restart() { let syncing_git_dir = tempfile::tempdir().expect("create syncing git dir"); let syncing_data_dir = tempfile::tempdir().expect("create syncing relay data dir"); let syncing = TestRelay::start_on_reservation_persistent_sync_with_recursive_limit( - reserve_port(), + syncing_reservation, None, true, syncing_git_dir.path().to_path_buf(), @@ -72,10 +74,7 @@ async fn historic_recursive_cap_is_enforced_and_reconstructed_after_restart() { LIMIT, ) .await; - let domains = [source.domain(), syncing.domain()]; - let domain_refs = domains.iter().map(String::as_str).collect::>(); - let (_target_announcement, _target_git) = - setup_announcement_on_relay(&syncing, &keys, &domain_refs, repo_id).await; + repository.install(&syncing).await; for descendant in [&first, &second] { assert!( @@ -154,12 +153,14 @@ async fn historic_sync_recovers_recursive_parent_only_descendants() { // Seed the source before the syncing relay exists so this exercises the // complete historic rotation, not ordinary live coverage. - let source_domains = [source.domain()]; + let syncing_reservation = reserve_port(); + let syncing_domain = format!("127.0.0.1:{}", syncing_reservation.port()); + let source_domains = [source.domain(), syncing_domain]; let source_refs = source_domains .iter() .map(String::as_str) .collect::>(); - let (_announcement, _source_git) = + let (_announcement, repository) = setup_announcement_on_relay(&source, &keys, &source_refs, repo_id).await; let source_client = TestClient::new(source.url(), keys.clone()) .await @@ -188,11 +189,9 @@ async fn historic_sync_recovers_recursive_parent_only_descendants() { .expect("seed source event"); } - let syncing = TestRelay::start_with_sync(None).await; - let domains = [source.domain(), syncing.domain()]; - let domain_refs = domains.iter().map(String::as_str).collect::>(); - let (_target_announcement, _target_git) = - setup_announcement_on_relay(&syncing, &keys, &domain_refs, repo_id).await; + let syncing = + TestRelay::start_on_reservation_with_options(syncing_reservation, None, false).await; + repository.install(&syncing).await; assert!( wait_for_event_on_relay( @@ -235,12 +234,14 @@ async fn descendant_live_coverage_is_preferred_when_capacity_remains() { let source = TestRelay::start().await; let keys = Keys::generate(); let repo_id = "live-descendant-coverage"; - let source_domains = [source.domain()]; + let syncing_reservation = reserve_port(); + let syncing_domain = format!("127.0.0.1:{}", syncing_reservation.port()); + let source_domains = [source.domain(), syncing_domain]; let source_refs = source_domains .iter() .map(String::as_str) .collect::>(); - let (_announcement, _source_git) = + let (_announcement, repository) = setup_announcement_on_relay(&source, &keys, &source_refs, repo_id).await; let source_client = TestClient::new(source.url(), keys.clone()) .await @@ -252,11 +253,9 @@ async fn descendant_live_coverage_is_preferred_when_capacity_remains() { source_client.send_event(&issue).await.unwrap(); source_client.send_event(&direct_reply).await.unwrap(); - let syncing = TestRelay::start_with_sync(None).await; - let domains = [source.domain(), syncing.domain()]; - let domain_refs = domains.iter().map(String::as_str).collect::>(); - let (_target_announcement, _target_git) = - setup_announcement_on_relay(&syncing, &keys, &domain_refs, repo_id).await; + let syncing = + TestRelay::start_on_reservation_with_options(syncing_reservation, None, false).await; + repository.install(&syncing).await; assert!( wait_for_log( &syncing.log_path(), @@ -295,12 +294,14 @@ async fn historic_sync_recovers_address_tag_descendants() { let source = TestRelay::start_with_relay_max_subscriptions(4).await; let keys = Keys::generate(); let repo_id = "addressable-descendant-history"; - let source_domains = [source.domain()]; + let syncing_reservation = reserve_port(); + let syncing_domain = format!("127.0.0.1:{}", syncing_reservation.port()); + let source_domains = [source.domain(), syncing_domain]; let source_refs = source_domains .iter() .map(String::as_str) .collect::>(); - let (_announcement, _source_git) = + let (_announcement, repository) = setup_announcement_on_relay(&source, &keys, &source_refs, repo_id).await; let source_client = TestClient::new(source.url(), keys.clone()) .await @@ -334,11 +335,9 @@ async fn historic_sync_recovers_address_tag_descendants() { source_client.send_event(event).await.unwrap(); } - let syncing = TestRelay::start_with_sync(None).await; - let domains = [source.domain(), syncing.domain()]; - let domain_refs = domains.iter().map(String::as_str).collect::>(); - let (_target_announcement, _target_git) = - setup_announcement_on_relay(&syncing, &keys, &domain_refs, repo_id).await; + let syncing = + TestRelay::start_on_reservation_with_options(syncing_reservation, None, false).await; + repository.install(&syncing).await; for event in &address_only_children { assert!( diff --git a/tests/sync/discovery.rs b/tests/sync/discovery.rs index 84f549a..ef9315c 100644 --- a/tests/sync/discovery.rs +++ b/tests/sync/discovery.rs @@ -61,7 +61,7 @@ async fn test_discovers_layer3_via_layer2() { let domains = [relay_a.domain(), relay_b.domain()]; let domain_refs: Vec<&str> = domains.iter().map(|s| s.as_str()).collect(); - let (announcement, _git_dir_a) = + let (announcement, repository) = setup_announcement_on_relay(&relay_a, &keys, &domain_refs, repo_id).await; let announcement_id = announcement.id; println!( @@ -102,8 +102,7 @@ async fn test_discovers_layer3_via_layer2() { client_a.disconnect().await; // 8. Set up announcement on relay_b (triggers discovery of relay_a) - let (_announcement_b, _git_dir_b) = - setup_announcement_on_relay(&relay_b, &keys, &domain_refs, repo_id).await; + repository.install(&relay_b).await; println!("Announcement set up on relay_b (should trigger discovery of relay_a)"); // 9/10. Verify the patch syncs to relay_b. The bounded poll on the synced @@ -176,7 +175,7 @@ async fn test_relay_discovery_via_announcements_with_historic_sync() { let domain_refs: Vec<&str> = domains.iter().map(|s| s.as_str()).collect(); let repo_id = "test-repo-chain"; - let (announcement, _git_dir_a) = + let (announcement, repository) = setup_announcement_on_relay(&relay_a, &keys, &domain_refs, repo_id).await; let announcement_id = announcement.id; println!( @@ -207,8 +206,7 @@ async fn test_relay_discovery_via_announcements_with_historic_sync() { client_a.disconnect().await; // 6. Set up announcement on relay_b (triggers discovery of relay_a) - let (_announcement_b, _git_dir_b) = - setup_announcement_on_relay(&relay_b, &keys, &domain_refs, repo_id).await; + repository.install(&relay_b).await; println!("Announcement set up on relay_b (should trigger discovery of relay_a)"); // 7/8. Verify the Layer 2 event syncs to relay_b. The bounded poll on the diff --git a/tests/sync/live_sync.rs b/tests/sync/live_sync.rs index 4961b6c..fb9f1bd 100644 --- a/tests/sync/live_sync.rs +++ b/tests/sync/live_sync.rs @@ -299,12 +299,11 @@ async fn test_live_sync_layer2_events() { let domains = [relay_a.domain(), relay_b.domain()]; let domain_refs: Vec<&str> = domains.iter().map(|s| s.as_str()).collect(); - let (_announcement, _git_dir_a) = + let (_announcement, repository) = setup_announcement_on_relay(&relay_a, &keys, &domain_refs, repo_id).await; println!("Announcement set up on relay_a with git data"); - let (_announcement_b, _git_dir_b) = - setup_announcement_on_relay(&relay_b, &keys, &domain_refs, repo_id).await; + repository.install(&relay_b).await; println!("Announcement set up on relay_b with git data (triggers discovery)"); // 5. Wait for discovery: events published before the syncing relay has @@ -397,12 +396,11 @@ async fn test_live_sync_layer3_events() { let domains = [relay_a.domain(), relay_b.domain()]; let domain_refs: Vec<&str> = domains.iter().map(|s| s.as_str()).collect(); - let (_announcement, _git_dir_a) = + let (_announcement, repository) = setup_announcement_on_relay(&relay_a, &keys, &domain_refs, repo_id).await; println!("Announcement set up on relay_a with git data"); - let (_announcement_b, _git_dir_b) = - setup_announcement_on_relay(&relay_b, &keys, &domain_refs, repo_id).await; + repository.install(&relay_b).await; println!("Announcement set up on relay_b with git data (triggers discovery)"); // 3. Wait for discovery: events published before the syncing relay has @@ -573,10 +571,9 @@ async fn test_live_sync_event_ordering() { let domains = [relay_a.domain(), relay_b.domain()]; let domain_refs: Vec<&str> = domains.iter().map(|s| s.as_str()).collect(); - let (_announcement, _git_dir_a) = + let (_announcement, repository) = setup_announcement_on_relay(&relay_a, &keys, &domain_refs, repo_id).await; - let (_announcement_b, _git_dir_b) = - setup_announcement_on_relay(&relay_b, &keys, &domain_refs, repo_id).await; + repository.install(&relay_b).await; println!("Announcements set up on both relays with git data"); // 3. Wait for discovery: events published before the syncing relay has diff --git a/tests/sync/metrics.rs b/tests/sync/metrics.rs index 5bfe3d5..9ba34a8 100644 --- a/tests/sync/metrics.rs +++ b/tests/sync/metrics.rs @@ -243,7 +243,7 @@ async fn test_startup_sync_event_count() { let domains = [source_relay.domain(), syncing_relay.domain()]; let domain_refs: Vec<&str> = domains.iter().map(|s| s.as_str()).collect(); - let (announcement, _git_dir_source) = + let (announcement, repository) = setup_announcement_on_relay(&source_relay, &keys, &domain_refs, repo_id).await; println!( "Announcement {} set up on source relay with git data", @@ -286,8 +286,7 @@ async fn test_startup_sync_event_count() { source_client.disconnect().await; // 8. Set up announcement on SYNCING relay (triggers discovery of source relay) - let (_announcement_syncing, _git_dir_syncing) = - setup_announcement_on_relay(&syncing_relay, &keys, &domain_refs, repo_id).await; + repository.install(&syncing_relay).await; println!("Announcement set up on syncing relay (triggers discovery of source)"); // 9. Wait for discovery + sync on the observable condition the test diff --git a/tests/sync/repository_fixture.rs b/tests/sync/repository_fixture.rs new file mode 100644 index 0000000..1d71d04 --- /dev/null +++ b/tests/sync/repository_fixture.rs @@ -0,0 +1,104 @@ +//! Reusing a repository fixture must not mint replaceable event revisions. +use std::{collections::BTreeSet, time::Duration}; + +use nostr_sdk::prelude::*; + +use crate::common::{reserve_port, setup_announcement_on_relay, TestRelay}; + +async fn repository_event_ids( + relay: &TestRelay, + keys: &Keys, + identifier: &str, +) -> BTreeSet { + let client = Client::builder() + .authenticator(SignerAuthenticator::new(keys.clone())) + .build(); + client.add_relay(relay.url()).await.unwrap(); + client + .try_connect_relay(relay.url(), Duration::from_secs(5)) + .await + .expect("connect to installed repository"); + let events = client + .fetch_events( + Filter::new() + .author(keys.public_key()) + .identifier(identifier) + .kinds([Kind::GitRepoAnnouncement, Kind::RepoState]), + ) + .timeout(Duration::from_secs(5)) + .await + .expect("fetch installed repository events"); + client.disconnect().await; + events.iter().map(|event| event.id).collect() +} + +async fn remote_head(relay: &TestRelay, keys: &Keys, identifier: &str) -> String { + let url = format!( + "http://{}/{}/{}.git", + relay.domain(), + keys.public_key().to_bech32().unwrap(), + identifier + ); + let output = tokio::process::Command::from(grasp_audit::git_command()) + .args(["ls-remote", &url, "refs/heads/main"]) + .kill_on_drop(true) + .output() + .await + .expect("query repository ref"); + assert!(output.status.success()); + String::from_utf8(output.stdout) + .unwrap() + .split_whitespace() + .next() + .expect("main ref exists") + .to_string() +} + +#[tokio::test] +async fn reinstalling_shared_repository_preserves_event_ids_and_git_refs() { + tokio::time::timeout(Duration::from_secs(120), async { + let source = TestRelay::start().await; + let target_reservation = reserve_port(); + let target_domain = format!("127.0.0.1:{}", target_reservation.port()); + let domains = [source.domain(), target_domain]; + let refs = domains.iter().map(String::as_str).collect::>(); + let keys = Keys::generate(); + let identifier = "shared-fixture-identity"; + let (announcement, repository) = + setup_announcement_on_relay(&source, &keys, &refs, identifier).await; + let original_ids = repository_event_ids(&source, &keys, identifier).await; + assert_eq!( + original_ids.len(), + 2, + "announcement and state must both exist" + ); + assert!(original_ids.contains(&announcement.id)); + let original_head = remote_head(&source, &keys, identifier).await; + + // Start the peer only after seeding the source, matching historic sync. + let target = + TestRelay::start_on_reservation_with_options(target_reservation, None, false).await; + repository.install(&target).await; + assert_eq!( + repository_event_ids(&target, &keys, identifier).await, + original_ids + ); + assert_eq!(remote_head(&target, &keys, identifier).await, original_head); + + // Reinstall after sync has seen the same address. There must be no new + // event IDs, timestamps or competing Git state in either direction. + repository.install(&source).await; + repository.install(&target).await; + for relay in [&source, &target] { + assert_eq!( + repository_event_ids(relay, &keys, identifier).await, + original_ids + ); + assert_eq!(remote_head(relay, &keys, identifier).await, original_head); + } + target.stop().await; + source.stop().await; + }) + .await + .expect("shared repository setup and reinstalls must complete"); +} diff --git a/tests/sync/tag_variations.rs b/tests/sync/tag_variations.rs index 5fb764d..1e43ac5 100644 --- a/tests/sync/tag_variations.rs +++ b/tests/sync/tag_variations.rs @@ -61,12 +61,11 @@ async fn test_layer2_sync_with_lowercase_a_tag() { let domains = [relay_a.domain(), relay_b.domain()]; let domain_refs: Vec<&str> = domains.iter().map(|s| s.as_str()).collect(); - let (_announcement, _git_dir_a) = + let (_announcement, repository) = setup_announcement_on_relay(&relay_a, &keys, &domain_refs, repo_id).await; println!("Announcement set up on relay_a with git data"); - let (_announcement_b, _git_dir_b) = - setup_announcement_on_relay(&relay_b, &keys, &domain_refs, repo_id).await; + repository.install(&relay_b).await; println!("Announcement set up on relay_b with git data (triggers discovery)"); // 3. Wait for discovery: events published before the syncing relay has @@ -152,12 +151,11 @@ async fn test_layer2_sync_with_uppercase_a_tag() { let domains = [relay_a.domain(), relay_b.domain()]; let domain_refs: Vec<&str> = domains.iter().map(|s| s.as_str()).collect(); - let (_announcement, _git_dir_a) = + let (_announcement, repository) = setup_announcement_on_relay(&relay_a, &keys, &domain_refs, repo_id).await; println!("Announcement set up on relay_a with git data"); - let (_announcement_b, _git_dir_b) = - setup_announcement_on_relay(&relay_b, &keys, &domain_refs, repo_id).await; + repository.install(&relay_b).await; println!("Announcement set up on relay_b with git data (triggers discovery)"); // 3. Wait for discovery: events published before the syncing relay has @@ -245,12 +243,11 @@ async fn test_layer2_sync_with_q_tag() { let domains = [relay_a.domain(), relay_b.domain()]; let domain_refs: Vec<&str> = domains.iter().map(|s| s.as_str()).collect(); - let (_announcement, _git_dir_a) = + let (_announcement, repository) = setup_announcement_on_relay(&relay_a, &keys, &domain_refs, repo_id).await; println!("Announcement set up on relay_a with git data"); - let (_announcement_b, _git_dir_b) = - setup_announcement_on_relay(&relay_b, &keys, &domain_refs, repo_id).await; + repository.install(&relay_b).await; println!("Announcement set up on relay_b with git data (triggers discovery)"); // 3. Wait for discovery: events published before the syncing relay has @@ -344,12 +341,11 @@ async fn test_layer3_sync_with_lowercase_e_tag() { let domains = [relay_a.domain(), relay_b.domain()]; let domain_refs: Vec<&str> = domains.iter().map(|s| s.as_str()).collect(); - let (_announcement, _git_dir_a) = + let (_announcement, repository) = setup_announcement_on_relay(&relay_a, &keys, &domain_refs, repo_id).await; println!("Announcement set up on relay_a with git data"); - let (_announcement_b, _git_dir_b) = - setup_announcement_on_relay(&relay_b, &keys, &domain_refs, repo_id).await; + repository.install(&relay_b).await; println!("Announcement set up on relay_b with git data (triggers discovery)"); // 3. Wait for discovery: events published before the syncing relay has @@ -460,12 +456,11 @@ async fn test_layer3_sync_with_uppercase_e_tag() { let domains = [relay_a.domain(), relay_b.domain()]; let domain_refs: Vec<&str> = domains.iter().map(|s| s.as_str()).collect(); - let (_announcement, _git_dir_a) = + let (_announcement, repository) = setup_announcement_on_relay(&relay_a, &keys, &domain_refs, repo_id).await; println!("Announcement set up on relay_a with git data"); - let (_announcement_b, _git_dir_b) = - setup_announcement_on_relay(&relay_b, &keys, &domain_refs, repo_id).await; + repository.install(&relay_b).await; println!("Announcement set up on relay_b with git data (triggers discovery)"); // 3. Wait for discovery: events published before the syncing relay has @@ -580,12 +575,11 @@ async fn test_layer3_sync_with_q_tag() { let domains = [relay_a.domain(), relay_b.domain()]; let domain_refs: Vec<&str> = domains.iter().map(|s| s.as_str()).collect(); - let (_announcement, _git_dir_a) = + let (_announcement, repository) = setup_announcement_on_relay(&relay_a, &keys, &domain_refs, repo_id).await; println!("Announcement set up on relay_a with git data"); - let (_announcement_b, _git_dir_b) = - setup_announcement_on_relay(&relay_b, &keys, &domain_refs, repo_id).await; + repository.install(&relay_b).await; println!("Announcement set up on relay_b with git data (triggers discovery)"); // 3. Wait for discovery: events published before the syncing relay has