Files
ngit-grasp/tests/purgatory_sync.rs
DanConwayDev 5d02ad6b89 refactor: replace hardcoded Kind constants with rust-nostr variants
- Replace KIND_REPOSITORY_ANNOUNCEMENT with Kind::GitRepoAnnouncement
- Replace KIND_REPOSITORY_STATE with Kind::RepoState
- Replace KIND_PR with Kind::GitPullRequest
- Replace KIND_PR_UPDATE with Kind::GitPullRequestUpdate
- Replace KIND_USER_GRASP_LIST with Kind::GitUserGraspList
- Replace KIND_PATCH with Kind::GitPatch
- Replace KIND_ISSUE with Kind::GitIssue
- Replace KIND_COMMENT with Kind::Comment
- Replace all Kind::Custom(30617|30618|1617|1618|1619|1621|1111|10317) patterns
- Remove all hardcoded KIND_* constants from events.rs
- Update all match statements to use Kind enum directly
- Update all filter builders to use Kind variants
- Update all test helpers and assertions

Benefits:
- Type safety: compiler prevents wrong kind numbers
- Readability: Kind::GitRepoAnnouncement is self-documenting
- Maintainability: single source of truth (rust-nostr)
- IDE support: full autocompletion and refactoring
- Standards: aligns with rust-nostr best practices

Files modified: 21
Constants removed: 9
Patterns replaced: 100+
Tests passing: 222/222
2026-01-08 11:23:28 +00:00

1137 lines
43 KiB
Rust

//! Purgatory Sync Integration Tests
//!
//! Tests that verify purgatory sync behavior:
//! - Events entering purgatory are released when git data arrives
//! - Git push triggers unified processing
//! - Remote sync fetches git data and releases events
//!
//! # Test Strategy
//!
//! These tests verify the end-to-end purgatory flow:
//! 1. State/PR events go to purgatory when git data is missing
//! 2. Git push triggers `process_newly_available_git_data`
//! 3. Events are released from purgatory when git data becomes available
//!
//! # Running Tests
//!
//! ```bash
//! # Run all purgatory sync tests
//! cargo test --test purgatory_sync
//!
//! # Run specific test
//! cargo test --test purgatory_sync test_push_triggers_unified_processing
//!
//! # With output for debugging
//! cargo test --test purgatory_sync -- --nocapture
//! ```
mod common;
use common::{
add_commit_to_repo, build_repo_coord, check_ref_at_commit, create_pr_event,
create_pr_event_with_clone, create_repo_announcement, create_state_event,
create_test_repo_with_commit, push_ref_to_relay, push_to_relay, verify_event_not_served,
wait_for_event_served, wait_for_sync_connection, CommitVariant, MockRelay, SmartGitServer,
TestRelay,
};
use nostr_sdk::prelude::*;
use std::time::Duration;
/// Test that a git push triggers `process_newly_available_git_data` and
/// releases state events from purgatory.
///
/// Scenario:
/// 1. Start relay
/// 2. Create and send repository announcement
/// 3. Create and send state event (goes to purgatory - no git data yet)
/// 4. Verify event is NOT served (in purgatory)
/// 5. Git push the required commit
/// 6. Verify event IS now served (released from purgatory)
#[tokio::test]
async fn test_push_triggers_unified_processing() {
// 1. Start relay
let relay = TestRelay::start().await;
let keys = Keys::generate();
let identifier = "push-test-repo";
// 2. Create test repository locally with deterministic commit
let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
let commit_hash = create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest)
.expect("Failed to create test repo");
// 3. Create and send announcement
let announcement = create_repo_announcement(&keys, &[&relay.domain()], identifier);
let client = Client::new(keys.clone());
client
.add_relay(relay.url())
.await
.expect("Failed to add relay");
client.connect().await;
// Wait for connection to be established
tokio::time::sleep(Duration::from_millis(500)).await;
// Send announcement
client
.send_event(&announcement)
.await
.expect("Failed to send announcement");
// Small delay to ensure announcement is processed
tokio::time::sleep(Duration::from_millis(200)).await;
// 4. Create and send state event referencing the commit
// The state event has refs that point to commits we haven't pushed yet
let npub = keys.public_key().to_bech32().expect("Failed to get npub");
let clone_url = format!("http://{}/{}/{}.git", relay.domain(), npub, identifier);
let state_event = create_state_event(
&keys,
identifier,
&[("main", &commit_hash)],
&[],
&[&clone_url],
&[relay.url()],
)
.expect("Failed to create state event");
let state_event_id = state_event.id;
client
.send_event(&state_event)
.await
.expect("Failed to send state event");
// 5. Verify event is NOT served yet (in purgatory)
// Give a moment for the event to be processed into purgatory
tokio::time::sleep(Duration::from_millis(200)).await;
verify_event_not_served(relay.url(), &state_event_id, Duration::from_secs(1))
.await
.expect("State event should NOT be served before git push (should be in purgatory)");
// 6. Git push to relay (this should trigger process_newly_available_git_data)
push_to_relay(temp_dir.path(), &relay.domain(), &npub, identifier)
.expect("Git push should succeed");
// 7. Verify event IS now served (released from purgatory)
let found_event = wait_for_event_served(relay.url(), &state_event_id, Duration::from_secs(5))
.await
.expect("State event should be served after git push");
// Verify it's the same event
assert_eq!(found_event.id, state_event_id);
// Cleanup
client.disconnect().await;
relay.stop().await;
}
/// Test that a state event entering purgatory triggers remote git fetch
/// and is released once the git data is available.
///
/// Scenario:
/// 1. Start source relay with git repository containing test commit
/// 2. Start syncing relay that syncs from source
/// 3. Syncing relay syncs state event (goes to purgatory - no local git data)
/// 4. Wait for sync to fetch git data from source's clone URL
/// 5. Verify state event is released and served on syncing relay
#[tokio::test]
async fn test_state_event_syncs_from_remote() {
// 1. Start source relay
let source_relay = TestRelay::start().await;
let keys = Keys::generate();
let identifier = "state-sync-test-repo";
// Pre-allocate syncing relay port so we can include it in announcement
let syncing_port = TestRelay::find_free_port();
let syncing_domain = format!("127.0.0.1:{}", syncing_port);
// 2. Create test repository locally with deterministic commit
let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
let commit_hash = create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest)
.expect("Failed to create test repo");
let npub = keys.public_key().to_bech32().expect("Failed to get npub");
// 3. Create and send announcement listing BOTH relays
// This ensures the syncing relay will accept the state event when it syncs
let announcement = create_repo_announcement(
&keys,
&[&source_relay.domain(), &syncing_domain],
identifier,
);
let source_client = Client::new(keys.clone());
source_client
.add_relay(source_relay.url())
.await
.expect("Failed to add source relay");
source_client.connect().await;
// Wait for connection
tokio::time::sleep(Duration::from_millis(500)).await;
// Send announcement to source relay
source_client
.send_event(&announcement)
.await
.expect("Failed to send announcement to source");
tokio::time::sleep(Duration::from_millis(200)).await;
// 4. Create and send state event BEFORE pushing
// The state event goes to purgatory on source relay, which authorizes the push
let clone_urls = [
format!(
"http://{}/{}/{}.git",
source_relay.domain(),
npub,
identifier
),
format!("http://{}/{}/{}.git", syncing_domain, npub, identifier),
];
let relay_urls = [
source_relay.url().to_string(),
format!("ws://{}", syncing_domain),
];
let state_event = create_state_event(
&keys,
identifier,
&[("main", &commit_hash)],
&[],
&[&clone_urls[0], &clone_urls[1]],
&[&relay_urls[0], &relay_urls[1]],
)
.expect("Failed to create state event");
let state_event_id = state_event.id;
// Send state event to source relay (goes to purgatory - no git data yet)
source_client
.send_event(&state_event)
.await
.expect("Failed to send state event to source");
tokio::time::sleep(Duration::from_millis(200)).await;
// 5. Push git data to source relay
// The state event in purgatory authorizes this push
push_to_relay(temp_dir.path(), &source_relay.domain(), &npub, identifier)
.expect("Push to source should succeed");
// After push, state event should be released from purgatory on source relay
// Verify source relay is serving the state event
wait_for_event_served(source_relay.url(), &state_event_id, Duration::from_secs(5))
.await
.expect("State event should be served on source relay after push");
// 6. Start syncing relay (syncs from source)
let syncing_relay = TestRelay::start_on_port_with_options(
syncing_port,
Some(source_relay.url().to_string()),
false,
)
.await;
// Wait for sync connection to establish
wait_for_sync_connection(syncing_relay.url(), 1, Duration::from_secs(5))
.await
.expect("Sync connection should establish");
// 7. Wait for state event to be released on syncing relay
// The sync should:
// a) Fetch the announcement and state event from source relay
// b) Accept announcement (creates bare repo structure)
// c) Put state event in purgatory (git data missing on syncing relay)
// d) Fetch git data from source relay's clone URL
// e) Release the state event from purgatory
let found = wait_for_event_served(
syncing_relay.url(),
&state_event_id,
Duration::from_secs(30), // Allow time for sync + git fetch
)
.await;
assert!(
found.is_ok(),
"State event should be served after sync fetches git data: {:?}",
found.err()
);
// 8. Verify refs are correct on syncing relay
let ref_correct = check_ref_at_commit(
&syncing_domain,
&npub,
identifier,
"refs/heads/main",
&commit_hash,
)
.await
.expect("Failed to check ref");
assert!(ref_correct, "main branch should point to correct commit");
// Cleanup
source_client.disconnect().await;
syncing_relay.stop().await;
source_relay.stop().await;
}
/// Test that a PR event entering purgatory triggers remote commit fetch
/// and is released once the commit is available.
///
/// Scenario:
/// 1. Start source relay with repository announcement
/// 2. Create PR event (goes to purgatory - no git data yet)
/// 3. Push commit to refs/nostr/<event-id> (authorized by PR event in purgatory)
/// 4. PR event gets released from purgatory on source relay
/// 5. Start syncing relay
/// 6. Syncing relay syncs PR event (goes to purgatory - no local git data)
/// 7. Syncing relay fetches commit from source's clone URL
/// 8. Verify PR event is released and refs/nostr/<event-id> created on syncing relay
#[tokio::test]
async fn test_pr_event_syncs_from_remote() {
// 1. Start source relay
let source_relay = TestRelay::start().await;
let owner_keys = Keys::generate();
let pr_author_keys = Keys::generate();
let identifier = "pr-sync-test-repo";
// Pre-allocate syncing relay port so we can include it in announcement
let syncing_port = TestRelay::find_free_port();
let syncing_domain = format!("127.0.0.1:{}", syncing_port);
// 2. Create test repository locally with PR commit
let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
let commit_hash = create_test_repo_with_commit(temp_dir.path(), CommitVariant::PrTest)
.expect("Failed to create test repo");
let npub = owner_keys
.public_key()
.to_bech32()
.expect("Failed to get npub");
// 3. Create and send announcement listing BOTH relays
// This ensures the syncing relay will accept the PR event when it syncs
let announcement = create_repo_announcement(
&owner_keys,
&[&source_relay.domain(), &syncing_domain],
identifier,
);
let source_client = Client::new(owner_keys.clone());
source_client
.add_relay(source_relay.url())
.await
.expect("Failed to add source relay");
source_client.connect().await;
// Wait for connection
tokio::time::sleep(Duration::from_millis(500)).await;
// Send announcement to source relay (creates bare repo)
source_client
.send_event(&announcement)
.await
.expect("Failed to send announcement to source");
tokio::time::sleep(Duration::from_millis(200)).await;
// 4. Create and send PR event BEFORE pushing
// The PR event goes to purgatory on source relay, which authorizes the push
let repo_coord = build_repo_coord(&owner_keys, identifier);
let pr_event = create_pr_event(
&pr_author_keys,
&repo_coord,
&commit_hash,
"Test PR for sync",
)
.expect("Failed to create PR event");
let pr_event_id = pr_event.id;
// Send PR event to source relay using PR author's client
let pr_client = Client::new(pr_author_keys.clone());
pr_client
.add_relay(source_relay.url())
.await
.expect("Failed to add source relay for PR");
pr_client.connect().await;
tokio::time::sleep(Duration::from_millis(500)).await;
pr_client
.send_event(&pr_event)
.await
.expect("Failed to send PR event to source");
// Small delay to ensure PR event is processed into purgatory
tokio::time::sleep(Duration::from_millis(200)).await;
// 5. Push commit to refs/nostr/<event-id> on source relay
// The PR event in purgatory authorizes this push
let ref_name = format!("refs/nostr/{}", pr_event_id.to_hex());
push_ref_to_relay(
temp_dir.path(),
&source_relay.domain(),
&npub,
identifier,
&commit_hash,
&ref_name,
)
.expect("Push to refs/nostr/<event-id> should succeed");
// After push, PR event should be released from purgatory on source relay
wait_for_event_served(source_relay.url(), &pr_event_id, Duration::from_secs(5))
.await
.expect("PR event should be served on source relay after push");
// 6. Start syncing relay (syncs from source)
let syncing_relay = TestRelay::start_on_port_with_options(
syncing_port,
Some(source_relay.url().to_string()),
false,
)
.await;
// Wait for sync connection to establish
wait_for_sync_connection(syncing_relay.url(), 1, Duration::from_secs(5))
.await
.expect("Sync connection should establish");
// 7. Wait for PR event to be released on syncing relay
// The sync should:
// a) Fetch the announcement and PR event from source relay
// b) Accept announcement (creates bare repo structure)
// c) Put PR event in purgatory (commit missing on syncing relay)
// d) Fetch commit from source relay's clone URL
// e) Release the PR event from purgatory
// f) Create refs/nostr/<event-id> pointing to the commit
let found = wait_for_event_served(
syncing_relay.url(),
&pr_event_id,
Duration::from_secs(30), // Allow time for sync + git fetch
)
.await;
assert!(
found.is_ok(),
"PR event should be served after sync fetches commit: {:?}",
found.err()
);
// 8. Verify refs/nostr/<event-id> was created on syncing relay
let ref_correct =
check_ref_at_commit(&syncing_domain, &npub, identifier, &ref_name, &commit_hash)
.await
.expect("Failed to check PR ref");
assert!(
ref_correct,
"refs/nostr/<event-id> should point to PR commit"
);
// Cleanup
source_client.disconnect().await;
pr_client.disconnect().await;
syncing_relay.stop().await;
source_relay.stop().await;
}
/// Test that concurrent state and PR events for the same repository
/// both sync correctly.
///
/// Scenario:
/// 1. Start source relay with repo containing two commits (main branch + PR commit)
/// 2. Create and push both commits to source relay
/// 3. Send both state event and PR event to source relay
/// 4. Start syncing relay
/// 5. Wait for sync to fetch git data and release both events
/// 6. Verify both state event and PR event are served
/// 7. Verify refs are correct for both (main branch and refs/nostr/<event-id>)
#[tokio::test]
async fn test_concurrent_state_and_pr_sync() {
// 1. Start source relay
let source_relay = TestRelay::start().await;
let owner_keys = Keys::generate();
let pr_author_keys = Keys::generate();
let identifier = "concurrent-sync-test-repo";
// Pre-allocate syncing relay port so we can include it in announcement
let syncing_port = TestRelay::find_free_port();
let syncing_domain = format!("127.0.0.1:{}", syncing_port);
// 2. Create test repository with two commits
// First commit establishes the repo, second commit is used for both state and PR events
let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
let _first_commit = create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest)
.expect("Failed to create test repo");
// Add second commit - this becomes HEAD of main and is referenced by both events
// In a real scenario, the state event would reference the current branch state,
// and the PR would propose changes (which happen to be the same commit here for simplicity)
let head_commit =
add_commit_to_repo(temp_dir.path(), CommitVariant::PrTest).expect("Failed to add commit");
let npub = owner_keys
.public_key()
.to_bech32()
.expect("Failed to get npub");
// 3. Create and send announcement listing BOTH relays
let announcement = create_repo_announcement(
&owner_keys,
&[&source_relay.domain(), &syncing_domain],
identifier,
);
let source_client = Client::new(owner_keys.clone());
source_client
.add_relay(source_relay.url())
.await
.expect("Failed to add source relay");
source_client.connect().await;
// Wait for connection
tokio::time::sleep(Duration::from_millis(500)).await;
// Send announcement to source relay (creates bare repo)
source_client
.send_event(&announcement)
.await
.expect("Failed to send announcement to source");
tokio::time::sleep(Duration::from_millis(200)).await;
// 4. Create state event referencing the HEAD commit (pr_commit)
// After add_commit_to_repo, main points to pr_commit (which includes state_commit in history)
let clone_urls = [
format!(
"http://{}/{}/{}.git",
source_relay.domain(),
npub,
identifier
),
format!("http://{}/{}/{}.git", syncing_domain, npub, identifier),
];
let relay_urls = [
source_relay.url().to_string(),
format!("ws://{}", syncing_domain),
];
// State event references main at head_commit (the current HEAD)
let state_event = create_state_event(
&owner_keys,
identifier,
&[("main", &head_commit)],
&[],
&[&clone_urls[0], &clone_urls[1]],
&[&relay_urls[0], &relay_urls[1]],
)
.expect("Failed to create state event");
let state_event_id = state_event.id;
// Send state event to source relay (goes to purgatory - no git data yet)
source_client
.send_event(&state_event)
.await
.expect("Failed to send state event to source");
// 5. Create PR event referencing the same commit (head_commit)
// This simulates a PR that proposes the changes in head_commit
let repo_coord = build_repo_coord(&owner_keys, identifier);
let pr_event = create_pr_event(
&pr_author_keys,
&repo_coord,
&head_commit,
"Test PR for concurrent sync",
)
.expect("Failed to create PR event");
let pr_event_id = pr_event.id;
// Send PR event to source relay using PR author's client
let pr_client = Client::new(pr_author_keys.clone());
pr_client
.add_relay(source_relay.url())
.await
.expect("Failed to add source relay for PR");
pr_client.connect().await;
tokio::time::sleep(Duration::from_millis(500)).await;
pr_client
.send_event(&pr_event)
.await
.expect("Failed to send PR event to source");
tokio::time::sleep(Duration::from_millis(200)).await;
// 6. Push git data to source relay
// Push all branches (main contains both commits due to linear history)
push_to_relay(temp_dir.path(), &source_relay.domain(), &npub, identifier)
.expect("Push to source should succeed");
// Also push the PR ref
let pr_ref_name = format!("refs/nostr/{}", pr_event_id.to_hex());
push_ref_to_relay(
temp_dir.path(),
&source_relay.domain(),
&npub,
identifier,
&head_commit,
&pr_ref_name,
)
.expect("Push PR ref to source should succeed");
// After push, both events should be released from purgatory on source relay
wait_for_event_served(source_relay.url(), &state_event_id, Duration::from_secs(5))
.await
.expect("State event should be served on source relay after push");
wait_for_event_served(source_relay.url(), &pr_event_id, Duration::from_secs(5))
.await
.expect("PR event should be served on source relay after push");
// 7. Start syncing relay (syncs from source)
let syncing_relay = TestRelay::start_on_port_with_options(
syncing_port,
Some(source_relay.url().to_string()),
false,
)
.await;
// Wait for sync connection to establish
wait_for_sync_connection(syncing_relay.url(), 1, Duration::from_secs(5))
.await
.expect("Sync connection should establish");
// 8. Wait for BOTH events to be released on syncing relay
// The sync should fetch git data and release both events
let state_found = wait_for_event_served(
syncing_relay.url(),
&state_event_id,
Duration::from_secs(30),
)
.await;
assert!(
state_found.is_ok(),
"State event should be served after sync fetches git data: {:?}",
state_found.err()
);
let pr_found =
wait_for_event_served(syncing_relay.url(), &pr_event_id, Duration::from_secs(30)).await;
assert!(
pr_found.is_ok(),
"PR event should be served after sync fetches git data: {:?}",
pr_found.err()
);
// 9. Verify refs are correct on syncing relay
// Check main branch points to head_commit (the HEAD)
let main_ref_correct = check_ref_at_commit(
&syncing_domain,
&npub,
identifier,
"refs/heads/main",
&head_commit,
)
.await
.expect("Failed to check main ref");
assert!(
main_ref_correct,
"main branch should point to HEAD commit ({})",
head_commit
);
// Check refs/nostr/<event-id> points to the same commit
let pr_ref_correct = check_ref_at_commit(
&syncing_domain,
&npub,
identifier,
&pr_ref_name,
&head_commit,
)
.await
.expect("Failed to check PR ref");
assert!(
pr_ref_correct,
"refs/nostr/<event-id> should point to commit ({})",
head_commit
);
// Cleanup
source_client.disconnect().await;
pr_client.disconnect().await;
syncing_relay.stop().await;
source_relay.stop().await;
}
/// Test PR event clone tag sync with relay discovery from announcement tags and partial git data sync
/// from multiple servers (state and pr git data from different places)
///
/// This comprehensive test verifies:
/// 1. Relay discovery: syncing_relay discovers other relays from announcement's `relays` tag
/// 2. PR clone tag sync: PR events with `clone` tags have their URLs used during purgatory sync
/// 3. OID aggregation: OIDs can be aggregated from multiple sources when no single server has all data
///
/// ## Key Difference from Bootstrap-Based Sync
///
/// Unlike tests that use bootstrap relay configuration, this test:
/// - Starts syncing_relay with NO bootstrap relay
/// - Publishes announcement DIRECTLY to syncing_relay
/// - syncing_relay discovers source_grasp and mock_relay from announcement's `relays` tag
///
/// This validates the relay discovery mechanism that allows GRASP relays to find
/// and sync from other relays listed in repository announcements.
///
/// ## Architecture
///
/// ```text
/// ┌─────────────────────────┐ ┌─────────────────────────┐ ┌─────────────────────────┐
/// │ source_grasp │ │ mock_relay │ │ git_server │
/// │ (GRASP relay) │ │ (rust-nostr relay) │ │ (SimpleGitServer) │
/// │ │ │ │ │ │
/// │ Has: │ │ Has: │ │ Has: │
/// │ - Announcement │ │ - PR event │ │ - PR commit (commit_b) │
/// │ - State event (served) │ │ (served immediately, │ │ at refs/heads/main │
/// │ - refs/heads/main │ │ no purgatory) │ │ │
/// │ → commit_a │ │ │ │ Does NOT have: │
/// │ │ │ PR event has clone tag │ │ - commit_a │
/// │ Does NOT have: │ │ pointing to git_server │ │ │
/// │ - PR commit (commit_b) │ │ │ │ │
/// └─────────────────────────┘ └─────────────────────────┘ └─────────────────────────┘
/// │ │ │
/// └───────────────────────────────┼───────────────────────────────┘
/// ▼
/// ┌─────────────────────────────────────────────────────────────────────────────────────────┐
/// │ syncing_relay (GRASP relay under test) │
/// │ │
/// │ Flow: │
/// │ 1. Started with NO bootstrap relay (sync enabled but no initial connections) │
/// │ 2. Announcement published DIRECTLY to syncing_relay │
/// │ 3. Relay discovers source_grasp and mock_relay from announcement's `relays` tag │
/// │ 4. Syncs state event from source_grasp → purgatory (no commit_a locally) │
/// │ 5. Syncs PR event from mock_relay → purgatory (no commit_b locally) │
/// │ 6. Purgatory sync triggers │
/// │ 7. Fetches commit_a from source_grasp clone URL (from announcement clone tag) │
/// │ 8. Fetches commit_b from git_server (from PR event's clone tag) │
/// │ 9. Both events released when all OIDs available │
/// │ │
/// │ Result: │
/// │ - State event served │
/// │ - PR event served │
/// │ - refs/heads/main → commit_a (from source_grasp) │
/// │ - refs/nostr/<event-id> → commit_b (from git_server via PR clone tag) │
/// └─────────────────────────────────────────────────────────────────────────────────────────┘
/// ```
#[tokio::test]
async fn test_pr_event_clone_tag_sync_with_partial_oid_aggregation_from_multiple_server() {
// ========================================================================
// Step 1: Setup Repositories
// ========================================================================
// Repo A: main branch with commit_a (for state event)
let repo_a = tempfile::tempdir().expect("Failed to create temp dir for repo_a");
let commit_a = create_test_repo_with_commit(repo_a.path(), CommitVariant::StateTest)
.expect("Failed to create commit_a");
// Repo B: PR commit (commit_b) - different content
let repo_b = tempfile::tempdir().expect("Failed to create temp dir for repo_b");
let commit_b = create_test_repo_with_commit(repo_b.path(), CommitVariant::PrTest)
.expect("Failed to create commit_b");
// ========================================================================
// Step 2: Start Servers
// ========================================================================
// 1. source_grasp - GRASP relay with main branch data
let source_grasp = TestRelay::start().await;
// 2. mock_relay - rust-nostr relay for PR event (no validation, no purgatory)
let mock_relay = MockRelay::start().await;
// 3. git_server - SmartGitServer with PR commit only
// Using SmartGitServer because purgatory sync uses `git fetch --depth=1`
// which requires the Git Smart HTTP protocol (not dumb HTTP)
let git_server = SmartGitServer::start(repo_b.path()).await;
// 4. Pre-allocate syncing_relay port for announcement tags
let syncing_port = TestRelay::find_free_port();
let syncing_domain = format!("127.0.0.1:{}", syncing_port);
// ========================================================================
// Step 3: Setup source_grasp with announcement and state event
// ========================================================================
let owner_keys = Keys::generate();
let pr_author_keys = Keys::generate();
let identifier = "pr-clone-partial-oid-test";
let npub = owner_keys
.public_key()
.to_bech32()
.expect("Failed to get npub");
// Build URLs for announcement
// - clone tag: ONLY source_grasp (has main branch data)
// - relays tag: source_grasp + mock_relay (mock_relay will serve PR event)
let clone_url_source = format!(
"http://{}/{}/{}.git",
source_grasp.domain(),
npub,
identifier
);
let clone_url_syncing = format!("http://{}/{}/{}.git", syncing_domain, npub, identifier);
// Create announcement with custom clone/relay URLs
// Clone URLs: source_grasp + syncing (NOT git_server - PR commit only via PR's clone tag)
// Relay URLs: source_grasp + mock_relay + syncing
let announcement = nostr_sdk::EventBuilder::new(
Kind::GitRepoAnnouncement,
"Repository for PR clone tag + partial OID test",
)
.tags(vec![
nostr_sdk::Tag::identifier(identifier),
nostr_sdk::Tag::custom(
nostr_sdk::TagKind::custom("clone"),
vec![clone_url_source.clone(), clone_url_syncing.clone()],
),
nostr_sdk::Tag::custom(
nostr_sdk::TagKind::custom("relays"),
vec![
source_grasp.url().to_string(),
mock_relay.url().to_string(),
format!("ws://{}", syncing_domain),
],
),
])
.sign_with_keys(&owner_keys)
.expect("Failed to sign announcement");
// Connect to source_grasp and send announcement
let source_client = Client::new(owner_keys.clone());
source_client
.add_relay(source_grasp.url())
.await
.expect("Failed to add source_grasp relay");
source_client.connect().await;
tokio::time::sleep(Duration::from_millis(500)).await;
source_client
.send_event(&announcement)
.await
.expect("Failed to send announcement to source_grasp");
tokio::time::sleep(Duration::from_millis(200)).await;
// Create state event referencing commit_a
let state_event = create_state_event(
&owner_keys,
identifier,
&[("main", &commit_a)],
&[],
&[&clone_url_source, &clone_url_syncing],
&[
source_grasp.url(),
mock_relay.url(),
&format!("ws://{}", syncing_domain),
],
)
.expect("Failed to create state event");
let state_event_id = state_event.id;
// Send state event to source_grasp (goes to purgatory - no git data yet)
source_client
.send_event(&state_event)
.await
.expect("Failed to send state event to source_grasp");
tokio::time::sleep(Duration::from_millis(200)).await;
// Push main branch (commit_a) to source_grasp - releases state event
push_to_relay(repo_a.path(), &source_grasp.domain(), &npub, identifier)
.expect("Push to source_grasp should succeed");
// Verify state event is served on source_grasp
wait_for_event_served(source_grasp.url(), &state_event_id, Duration::from_secs(5))
.await
.expect("State event should be served on source_grasp after push");
// ========================================================================
// Step 4: Setup mock_relay with PR event
// ========================================================================
// First, send announcement to mock_relay so it has the repo context
// This is needed because the sync system filters events based on whether
// they reference repos that list our relay
let mock_client = Client::new(owner_keys.clone());
mock_client
.add_relay(mock_relay.url())
.await
.expect("Failed to add mock_relay for announcement");
mock_client.connect().await;
tokio::time::sleep(Duration::from_millis(500)).await;
mock_client
.send_event(&announcement)
.await
.expect("Failed to send announcement to mock_relay");
tokio::time::sleep(Duration::from_millis(200)).await;
let repo_coord = build_repo_coord(&owner_keys, identifier);
// Create PR event with clone tag pointing to git_server
// This is the KEY part - the PR's clone tag provides the URL for commit_b
let pr_event = create_pr_event_with_clone(
&pr_author_keys,
&repo_coord,
&commit_b,
"Test PR for partial OID aggregation",
&[git_server.url()], // Clone URL points to SimpleGitServer
)
.expect("Failed to create PR event");
let pr_event_id = pr_event.id;
// Send PR event to mock_relay
// MockRelay accepts all events without validation (no purgatory)
let pr_client = Client::new(pr_author_keys.clone());
pr_client
.add_relay(mock_relay.url())
.await
.expect("Failed to add mock_relay");
pr_client.connect().await;
tokio::time::sleep(Duration::from_millis(500)).await;
pr_client
.send_event(&pr_event)
.await
.expect("Failed to send PR event to mock_relay");
// Verify PR event is served on mock_relay (immediate, no purgatory)
wait_for_event_served(mock_relay.url(), &pr_event_id, Duration::from_secs(5))
.await
.expect("PR event should be served on mock_relay immediately");
// ========================================================================
// Step 5: Start syncing_relay WITHOUT bootstrap and publish announcement directly
// ========================================================================
// Start syncing_relay with sync enabled but NO bootstrap relay
// This tests relay discovery from announcement's `relays` tag
// Note: We disable negentropy because MockRelay doesn't support NIP-77,
// and the sync system doesn't properly fall back to REQ+EOSE when negentropy fails.
let syncing_relay = TestRelay::start_on_port_with_options(
syncing_port,
None, // NO bootstrap - relay discovery via announcement tags
true, // Disable negentropy - MockRelay doesn't support NIP-77
)
.await;
// Publish announcement DIRECTLY to syncing_relay
// This triggers relay discovery from the announcement's `relays` tag
let syncing_client = Client::new(owner_keys.clone());
syncing_client
.add_relay(syncing_relay.url())
.await
.expect("Failed to add syncing_relay");
syncing_client.connect().await;
tokio::time::sleep(Duration::from_millis(500)).await;
syncing_client
.send_event(&announcement)
.await
.expect("Failed to send announcement to syncing_relay");
tokio::time::sleep(Duration::from_millis(200)).await;
// Wait for relay discovery and sync connections to establish
// syncing_relay should discover source_grasp and mock_relay from announcement's relays tag
println!("=== Waiting for sync connections ===");
println!("syncing_relay URL: {}", syncing_relay.url());
println!("source_grasp URL: {}", source_grasp.url());
println!("mock_relay URL: {}", mock_relay.url());
println!("git_server URL: {}", git_server.url());
wait_for_sync_connection(syncing_relay.url(), 2, Duration::from_secs(10))
.await
.expect(
"Sync connections should establish to discovered relays (source_grasp + mock_relay)",
);
println!("Sync connections established!");
// Debug: Check metrics to see what relays are connected
let metrics_url = syncing_relay
.url()
.replace("ws://", "http://")
.replace("/", "")
+ "/metrics";
println!("Checking metrics at: {}", metrics_url);
if let Ok(response) = reqwest::get(&metrics_url).await {
if let Ok(metrics) = response.text().await {
// Print sync-related metrics
for line in metrics.lines() {
if line.contains("sync") && !line.starts_with('#') {
println!(" {}", line);
}
}
}
}
// Give some time for sync to happen
println!("Waiting 10s for events to sync...");
tokio::time::sleep(Duration::from_secs(10)).await;
// Check metrics again after waiting
println!("=== Checking metrics after sync wait ===");
if let Ok(response) = reqwest::get(&metrics_url).await {
if let Ok(metrics) = response.text().await {
for line in metrics.lines() {
if line.contains("sync") && !line.starts_with('#') {
println!(" {}", line);
}
}
}
}
// Debug: Check if PR event is still on mock_relay
println!("=== Debug: Checking PR event on mock_relay ===");
let pr_on_mock =
wait_for_event_served(mock_relay.url(), &pr_event_id, Duration::from_secs(2)).await;
println!("PR event on mock_relay: {:?}", pr_on_mock.is_ok());
if let Ok(ref pr) = pr_on_mock {
println!("PR event tags:");
for tag in pr.tags.iter() {
println!(" {:?}", tag.as_slice());
}
}
// Debug: Check repo coordinate
let repo_coord = build_repo_coord(&owner_keys, identifier);
println!("Expected repo coordinate: {}", repo_coord);
// Debug: Test if mock_relay responds to tag-based filter (Layer 2 style)
println!("=== Debug: Testing mock_relay tag filter response ===");
let test_client = Client::new(Keys::generate());
test_client
.add_relay(mock_relay.url())
.await
.expect("Failed to add mock_relay");
test_client.connect().await;
tokio::time::sleep(Duration::from_millis(500)).await;
// Build a Layer 2 style filter (by 'a' tag)
let tag_filter =
Filter::new().custom_tag(SingleLetterTag::lowercase(Alphabet::A), repo_coord.as_str());
println!("Tag filter: {:?}", tag_filter);
let tag_results = test_client
.fetch_events(tag_filter, Duration::from_secs(5))
.await;
match tag_results {
Ok(events) => {
println!("Tag filter returned {} events", events.len());
for event in events.iter() {
println!(" Event ID: {}, Kind: {}", event.id, event.kind.as_u16());
}
}
Err(e) => {
println!("Tag filter query failed: {:?}", e);
}
}
test_client.disconnect().await;
// The syncing relay will:
// 1. Receive announcement directly (creates bare repo)
// 2. Discover source_grasp and mock_relay from announcement's `relays` tag
// 3. Connect to discovered relays
// 4. Sync state event from source_grasp → purgatory (no commit_a locally)
// 5. Sync PR event from mock_relay → purgatory (no commit_b locally)
// 6. Purgatory sync triggers
// 7. Fetches commit_a from source_grasp clone URL (from announcement clone tag)
// 8. Fetches commit_b from git_server (from PR event's clone tag)
// 9. Both events released when all OIDs available
// ========================================================================
// Step 6: Verify Results
// ========================================================================
println!("=== Step 6: Verify Results ===");
println!("State event ID: {}", state_event_id);
println!("PR event ID: {}", pr_event_id);
println!("commit_a: {}", commit_a);
println!("commit_b: {}", commit_b);
// Wait for state event to be served on syncing_relay
println!("Waiting for state event on syncing_relay...");
let state_found = wait_for_event_served(
syncing_relay.url(),
&state_event_id,
Duration::from_secs(30),
)
.await;
println!("State event result: {:?}", state_found);
assert!(
state_found.is_ok(),
"State event should be served on syncing_relay: {:?}",
state_found.err()
);
// Wait for PR event to be served on syncing_relay
println!("Waiting for PR event on syncing_relay...");
let pr_found =
wait_for_event_served(syncing_relay.url(), &pr_event_id, Duration::from_secs(30)).await;
println!("PR event result: {:?}", pr_found);
assert!(
pr_found.is_ok(),
"PR event should be served on syncing_relay (fetched commit_b from git_server via PR clone tag): {:?}",
pr_found.err()
);
// Verify refs/heads/main → commit_a (from source_grasp)
let main_correct = check_ref_at_commit(
&syncing_domain,
&npub,
identifier,
"refs/heads/main",
&commit_a,
)
.await
.expect("Failed to check main ref");
assert!(
main_correct,
"main should point to commit_a ({}) from source_grasp",
commit_a
);
// Verify refs/nostr/<event-id> → commit_b (from git_server via PR clone tag)
let pr_ref = format!("refs/nostr/{}", pr_event_id.to_hex());
let pr_correct = check_ref_at_commit(&syncing_domain, &npub, identifier, &pr_ref, &commit_b)
.await
.expect("Failed to check PR ref");
assert!(
pr_correct,
"PR ref should point to commit_b ({}) fetched from git_server via PR clone tag",
commit_b
);
// ========================================================================
// Step 7: Cleanup
// ========================================================================
source_client.disconnect().await;
mock_client.disconnect().await;
pr_client.disconnect().await;
syncing_client.disconnect().await;
git_server.stop().await;
mock_relay.stop().await;
syncing_relay.stop().await;
source_grasp.stop().await;
}