test: added purgatory git data sync intergration tests agregating from mulitple git servers

This commit is contained in:
DanConwayDev
2026-01-07 20:41:01 +00:00
parent e7c18cf2a9
commit 7467aa9ace
4 changed files with 930 additions and 19 deletions
+336
View File
@@ -0,0 +1,336 @@
//! Mock Nostr Relay for Testing
//!
//! Provides a simple Nostr relay that accepts all events without validation.
//! Uses rust-nostr's `LocalRelayBuilder` to create an in-memory relay.
//!
//! # Usage
//!
//! ```ignore
//! use common::MockRelay;
//!
//! #[tokio::test]
//! async fn test_mock_relay() {
//! // Start the mock relay
//! let mock = MockRelay::start().await;
//!
//! // Use mock.url() for WebSocket connections
//! let client = Client::new(keys);
//! client.add_relay(mock.url()).await.unwrap();
//!
//! // All events are accepted without validation
//! client.send_event(&event).await.unwrap();
//!
//! // Cleanup
//! mock.stop().await;
//! }
//! ```
//!
//! # How It Works
//!
//! The mock relay:
//! - Uses `LocalRelayBuilder::default().build()` which accepts all events
//! - Runs an HTTP server with WebSocket upgrade support
//! - Stores events in an in-memory database
//! - Does NOT perform any GRASP validation (no purgatory, no git data checks)
use std::net::SocketAddr;
use std::sync::Arc;
use http_body_util::Full;
use hyper::body::Bytes;
use hyper::header::{CONNECTION, SEC_WEBSOCKET_ACCEPT, SEC_WEBSOCKET_KEY, UPGRADE};
use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper::{Request, Response, StatusCode};
use hyper_util::rt::TokioIo;
use nostr_relay_builder::prelude::*;
use tokio::net::TcpListener;
use tokio::sync::oneshot;
/// Mock Nostr relay that accepts all events without validation.
///
/// This relay is useful for testing scenarios where you need a relay
/// that serves events without GRASP validation (no purgatory, no git checks).
pub struct MockRelay {
/// Shutdown signal sender
shutdown_tx: Option<oneshot::Sender<()>>,
/// Server task handle
handle: Option<tokio::task::JoinHandle<()>>,
/// Server URL (ws://127.0.0.1:<port>)
url: String,
/// Server port
#[allow(dead_code)]
port: u16,
/// The underlying LocalRelay (kept alive for the server lifetime)
#[allow(dead_code)]
relay: LocalRelay,
}
impl MockRelay {
/// Start a mock relay on a random free port.
///
/// The relay accepts all events without validation and stores them
/// in an in-memory database.
pub async fn start() -> Self {
let port = find_free_port();
Self::start_on_port(port).await
}
/// Start a mock relay on a specific port.
pub async fn start_on_port(port: u16) -> Self {
let addr: SocketAddr = ([127, 0, 0, 1], port).into();
// Create a simple relay with no write policy (accepts all events)
let relay = LocalRelayBuilder::default().build();
// Create shutdown channel
let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>();
// Clone relay for the server task
let server_relay = relay.clone();
// Start the HTTP/WebSocket server
let listener = TcpListener::bind(addr)
.await
.expect("Failed to bind to address");
let handle = tokio::spawn(async move {
loop {
tokio::select! {
accept_result = listener.accept() => {
match accept_result {
Ok((stream, remote_addr)) => {
let relay = server_relay.clone();
let io = TokioIo::new(stream);
tokio::spawn(async move {
let service = service_fn(move |req| {
let relay = relay.clone();
async move { handle_request(req, relay, remote_addr).await }
});
if let Err(e) = http1::Builder::new()
.serve_connection(io, service)
.with_upgrades()
.await
{
// Connection errors are expected when client disconnects
if !e.to_string().contains("connection") {
eprintln!("MockRelay connection error: {}", e);
}
}
});
}
Err(e) => {
eprintln!("MockRelay accept error: {}", e);
}
}
}
_ = &mut shutdown_rx => {
// Shutdown signal received
break;
}
}
}
});
let url = format!("ws://127.0.0.1:{}", port);
// Wait for server to be ready
wait_for_server_ready(port).await;
Self {
shutdown_tx: Some(shutdown_tx),
handle: Some(handle),
url,
port,
relay,
}
}
/// Get the relay WebSocket URL.
pub fn url(&self) -> &str {
&self.url
}
/// Stop the mock relay.
pub async fn stop(mut self) {
// Send shutdown signal
if let Some(tx) = self.shutdown_tx.take() {
let _ = tx.send(());
}
// Wait for server task to complete
if let Some(handle) = self.handle.take() {
let _ = handle.await;
}
}
}
impl Drop for MockRelay {
fn drop(&mut self) {
// Send shutdown signal if not already sent
if let Some(tx) = self.shutdown_tx.take() {
let _ = tx.send(());
}
}
}
/// Handle an HTTP request, upgrading to WebSocket if requested.
async fn handle_request(
req: Request<hyper::body::Incoming>,
relay: LocalRelay,
addr: SocketAddr,
) -> Result<Response<Full<Bytes>>, hyper::Error> {
// Check for WebSocket upgrade request
let is_websocket = req
.headers()
.get(UPGRADE)
.map(|v| v.to_str().unwrap_or("").to_lowercase() == "websocket")
.unwrap_or(false);
if is_websocket {
// Get the Sec-WebSocket-Key header
let key = req
.headers()
.get(SEC_WEBSOCKET_KEY)
.and_then(|k| k.to_str().ok())
.map(|k| k.to_string());
if let Some(key) = key {
let accept_key = derive_accept_key(key.as_bytes());
// Spawn task to handle the upgraded connection
tokio::spawn(async move {
match hyper::upgrade::on(req).await {
Ok(upgraded) => {
if let Err(e) = relay.take_connection(TokioIo::new(upgraded), addr).await {
eprintln!("MockRelay WebSocket error: {}", e);
}
}
Err(e) => eprintln!("MockRelay upgrade error: {}", e),
}
});
// Return 101 Switching Protocols
return Ok(Response::builder()
.status(StatusCode::SWITCHING_PROTOCOLS)
.header(CONNECTION, "upgrade")
.header(UPGRADE, "websocket")
.header(SEC_WEBSOCKET_ACCEPT, accept_key)
.body(Full::new(Bytes::new()))
.unwrap());
}
}
// Non-WebSocket request - return simple response
Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "text/plain")
.body(Full::new(Bytes::from("MockRelay - Nostr test relay")))
.unwrap())
}
/// Derive the Sec-WebSocket-Accept key from the request key.
fn derive_accept_key(request_key: &[u8]) -> String {
use nostr_sdk::hashes::sha1::Hash as Sha1Hash;
use nostr_sdk::hashes::{Hash, HashEngine};
const WS_GUID: &[u8] = b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
let mut engine = Sha1Hash::engine();
engine.input(request_key);
engine.input(WS_GUID);
let hash = Sha1Hash::from_engine(engine);
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, hash.as_byte_array())
}
/// Find a free port to use for the server.
fn find_free_port() -> u16 {
use std::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").expect("Failed to bind to random port");
let port = listener
.local_addr()
.expect("Failed to get local addr")
.port();
drop(listener);
port
}
/// Wait for the server to be ready to accept connections.
async fn wait_for_server_ready(port: u16) {
let max_attempts = 50; // 5 seconds total
let delay = std::time::Duration::from_millis(100);
for attempt in 0..max_attempts {
match tokio::net::TcpStream::connect(format!("127.0.0.1:{}", port)).await {
Ok(_) => {
// Connection successful, server is ready
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
return;
}
Err(_) => {
if attempt == max_attempts - 1 {
panic!(
"MockRelay failed to start after {} attempts",
max_attempts
);
}
tokio::time::sleep(delay).await;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use nostr_sdk::prelude::*;
use std::time::Duration;
#[tokio::test]
async fn test_mock_relay_starts_and_stops() {
let mock = MockRelay::start().await;
// Verify URL is set
assert!(mock.url().starts_with("ws://127.0.0.1:"));
mock.stop().await;
}
#[tokio::test]
async fn test_mock_relay_accepts_events() {
let mock = MockRelay::start().await;
// Create a client and connect
let keys = Keys::generate();
let client = Client::new(keys.clone());
client.add_relay(mock.url()).await.expect("Failed to add relay");
client.connect().await;
// Wait for connection
tokio::time::sleep(Duration::from_millis(500)).await;
// Create and send a simple event
let event = EventBuilder::text_note("Test note from MockRelay test")
.sign_with_keys(&keys)
.expect("Failed to sign event");
let result = client.send_event(&event).await;
assert!(result.is_ok(), "MockRelay should accept events");
// Verify event was stored by fetching it back
let filter = Filter::new().id(event.id);
let events = client
.fetch_events(filter, Duration::from_secs(2))
.await
.expect("Failed to fetch events");
assert!(!events.is_empty(), "Event should be stored and retrievable");
assert_eq!(events.first().unwrap().id, event.id);
client.disconnect().await;
mock.stop().await;
}
}
+2
View File
@@ -3,11 +3,13 @@
#![allow(unused_imports)] // Re-exports may not be used in all test configurations
pub mod git_server;
pub mod mock_relay;
pub mod purgatory_helpers;
pub mod relay;
pub mod sync_helpers;
pub use git_server::SimpleGitServer;
pub use mock_relay::MockRelay;
pub use purgatory_helpers::*;
pub use relay::TestRelay;
pub use sync_helpers::*;
+115
View File
@@ -271,6 +271,60 @@ pub fn create_pr_event(
.map_err(|e| format!("Failed to sign PR event: {}", e))
}
/// Create a PR event (kind 1618) with clone URLs.
///
/// Creates a properly formatted NIP-34 PR event that references a repository
/// via an `a` tag, includes the commit hash via a `c` tag, and specifies
/// clone URLs where the PR commit can be fetched from.
///
/// Per NIP-34, PR events can include a `clone` tag:
/// ```jsonc
/// {
/// "kind": 1618,
/// "tags": [
/// ["c", "<current-commit-id>"],
/// ["clone", "<clone-url>", ...], // at least one git clone url where commit can be downloaded
/// // ...
/// ]
/// }
/// ```
///
/// # Arguments
/// * `keys` - Keys for signing
/// * `repo_coord` - Repository coordinate (format: "30617:pubkey_hex:identifier")
/// * `commit_hash` - The commit hash (c-tag)
/// * `title` - PR title (used as content)
/// * `clone_urls` - Clone URLs where the PR commit can be fetched
///
/// # Returns
/// * `Ok(Event)` - Signed PR event ready to send
/// * `Err(String)` - If signing fails
pub fn create_pr_event_with_clone(
keys: &Keys,
repo_coord: &str,
commit_hash: &str,
title: &str,
clone_urls: &[&str],
) -> Result<Event, String> {
let mut tags = vec![
// a-tag referencing the repository
Tag::custom(TagKind::custom("a"), vec![repo_coord.to_string()]),
// c-tag with the commit hash
Tag::custom(TagKind::custom("c"), vec![commit_hash.to_string()]),
];
// Add clone URLs if provided
if !clone_urls.is_empty() {
let urls: Vec<String> = clone_urls.iter().map(|s| s.to_string()).collect();
tags.push(Tag::custom(TagKind::Clone, urls));
}
EventBuilder::new(Kind::Custom(KIND_PR), title)
.tags(tags)
.sign_with_keys(keys)
.map_err(|e| format!("Failed to sign PR event: {}", e))
}
/// Build a repository coordinate string for use in 'a' tags.
///
/// Format: `30617:pubkey_hex:identifier`
@@ -738,4 +792,65 @@ mod tests {
let branch_commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert_eq!(branch_commit, commit_hash);
}
#[test]
fn test_create_pr_event_with_clone_has_correct_tags() {
let keys = Keys::generate();
let repo_coord = build_repo_coord(&keys, "test-repo");
let event = create_pr_event_with_clone(
&keys,
&repo_coord,
"abc123def456",
"Test PR with clone",
&["http://fork-server.com/repo.git", "http://another-server.com/repo.git"],
)
.expect("Failed to create PR event with clone");
assert_eq!(event.kind.as_u16(), KIND_PR);
// Check a-tag
let has_a_tag = event.tags.iter().any(|tag| {
let slice = tag.as_slice();
slice.first().is_some_and(|t| t == "a") && slice.get(1).is_some_and(|v| v == &repo_coord)
});
assert!(has_a_tag, "Event should have 'a' tag");
// Check c-tag
let has_c_tag = event.tags.iter().any(|tag| {
let slice = tag.as_slice();
slice.first().is_some_and(|t| t == "c")
&& slice.get(1).is_some_and(|v| v == "abc123def456")
});
assert!(has_c_tag, "Event should have 'c' tag with commit");
// Check clone tag with both URLs
let has_clone_tag = event.tags.iter().any(|tag| {
let slice = tag.as_slice();
slice.first().is_some_and(|t| t == "clone")
&& slice.get(1).is_some_and(|v| v == "http://fork-server.com/repo.git")
&& slice.get(2).is_some_and(|v| v == "http://another-server.com/repo.git")
});
assert!(has_clone_tag, "Event should have 'clone' tag with URLs");
}
#[test]
fn test_create_pr_event_with_clone_empty_urls() {
let keys = Keys::generate();
let repo_coord = build_repo_coord(&keys, "test-repo");
let event = create_pr_event_with_clone(
&keys,
&repo_coord,
"abc123def456",
"Test PR without clone URLs",
&[], // Empty clone URLs
)
.expect("Failed to create PR event");
// Should not have clone tag when no URLs provided
let has_clone_tag = event.tags.iter().any(|tag| {
let slice = tag.as_slice();
slice.first().is_some_and(|t| t == "clone")
});
assert!(!has_clone_tag, "Event should not have 'clone' tag when no URLs provided");
}
}
+477 -19
View File
@@ -29,9 +29,10 @@ mod common;
use common::{
add_commit_to_repo, build_repo_coord, check_ref_at_commit, create_pr_event,
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, TestRelay,
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, SimpleGitServer,
TestRelay,
};
use nostr_sdk::prelude::*;
use std::time::Duration;
@@ -55,9 +56,8 @@ async fn test_push_triggers_unified_processing() {
// 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 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);
@@ -343,8 +343,13 @@ async fn test_pr_event_syncs_from_remote() {
// 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 = 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;
@@ -418,15 +423,10 @@ async fn test_pr_event_syncs_from_remote() {
);
// 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");
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,
@@ -624,8 +624,8 @@ async fn test_concurrent_state_and_pr_sync() {
state_found.err()
);
let pr_found = wait_for_event_served(syncing_relay.url(), &pr_event_id, Duration::from_secs(30))
.await;
let pr_found =
wait_for_event_served(syncing_relay.url(), &pr_event_id, Duration::from_secs(30)).await;
assert!(
pr_found.is_ok(),
@@ -674,3 +674,461 @@ async fn test_concurrent_state_and_pr_sync() {
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 - SimpleGitServer with PR commit only
let git_server = SimpleGitServer::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(
nostr_sdk::Kind::Custom(30617),
"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;
}