mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-05 23:18:24 +00:00
test: added purgatory git data sync intergration tests agregating from mulitple git servers
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user