//! Test relay fixture //! //! Provides automatic relay lifecycle management for integration tests. use nostr_sdk::ToBech32; use std::path::PathBuf; use std::process::{Child, Command, Stdio}; use std::time::Duration; use tokio::time::sleep; /// Test relay fixture that manages relay lifecycle /// /// Automatically starts and stops the ngit-grasp relay for testing. /// Uses a random port to avoid conflicts and cleans up created repositories. pub struct TestRelay { process: Child, url: String, port: u16, /// Temporary directory for git repositories /// Kept alive for the lifetime of the relay _git_data_dir: tempfile::TempDir, /// Path to git data directory (for test assertions) git_data_path: PathBuf, } impl TestRelay { /// Start a test relay instance /// /// # Example /// /// ```no_run /// use common::TestRelay; /// /// #[tokio::test] /// async fn test_something() { /// let relay = TestRelay::start().await; /// // Use relay.url() for testing /// relay.stop().await; /// } /// ``` pub async fn start() -> Self { Self::start_with_options(Self::find_free_port(), None).await } /// Start relay on a specific port pub async fn start_with_port(port: u16) -> Self { Self::start_with_full_options(port, None, false).await } /// Start relay on a specific port with full options /// /// This is useful for testing history sync where we need to: /// 1. Start relay_b (first instance) to get its domain /// 2. Stop relay_b /// 3. Start relay_b (second instance) on SAME port with different options pub async fn start_on_port_with_options( port: u16, bootstrap_relay_url: Option, disable_negentropy: bool, ) -> Self { Self::start_with_full_options(port, bootstrap_relay_url, disable_negentropy).await } /// Start relay with sync from another relay (bootstrap relay) /// /// # Example /// /// ```no_run /// use common::TestRelay; /// /// #[tokio::test] /// async fn test_sync() { /// let source = TestRelay::start().await; /// let syncing = TestRelay::start_with_sync(source.url()).await; /// // ... test sync behavior ... /// syncing.stop().await; /// source.stop().await; /// } /// ``` pub async fn start_with_sync(bootstrap_relay_url: Option) -> Self { Self::start_with_full_options(Self::find_free_port(), bootstrap_relay_url, false).await } /// Start relay with sync and negentropy disabled /// /// This is useful for testing that sync works without NIP-77 negentropy. /// History sync will use REQ+EOSE instead of the more efficient negentropy protocol. /// /// # Example /// /// ```no_run /// use common::TestRelay; /// /// #[tokio::test] /// async fn test_sync_without_negentropy() { /// let source = TestRelay::start().await; /// let syncing = TestRelay::start_with_sync_no_negentropy(Some(source.url().into())).await; /// // ... test sync behavior without negentropy ... /// syncing.stop().await; /// source.stop().await; /// } /// ``` pub async fn start_with_sync_no_negentropy(bootstrap_relay_url: Option) -> Self { Self::start_with_full_options(Self::find_free_port(), bootstrap_relay_url, true).await } /// Start relay with archive configuration /// /// This is useful for testing GRASP-05 archive mode behavior. /// /// # Arguments /// * `archive_all` - Accept all repository announcements (GRASP-05) /// * `archive_read_only` - Reject git pushes (read-only archive mode) /// /// # Example /// /// ```no_run /// use common::TestRelay; /// /// #[tokio::test] /// async fn test_archive_mode() { /// let relay = TestRelay::start_with_archive_config(true, true).await; /// // ... test archive behavior ... /// relay.stop().await; /// } /// ``` pub async fn start_with_archive_config(archive_all: bool, archive_read_only: bool) -> Self { Self::start_with_archive_and_sync( Self::find_free_port(), None, false, archive_all, archive_read_only, ) .await } /// Start relay with options (internal, maintains backward compatibility) async fn start_with_options(port: u16, bootstrap_relay_url: Option) -> Self { Self::start_with_full_options(port, bootstrap_relay_url, false).await } /// Start relay with full options async fn start_with_full_options( port: u16, bootstrap_relay_url: Option, disable_negentropy: bool, ) -> Self { Self::start_with_archive_and_sync( port, bootstrap_relay_url, disable_negentropy, false, false, ) .await } /// Start relay with all options including archive configuration and sync /// /// This is the most flexible method for starting a test relay with all options. /// Use this when you need both archive mode AND sync from a bootstrap relay. /// /// # Arguments /// * `port` - Port to bind to /// * `bootstrap_relay_url` - URL of relay to sync from (optional) /// * `disable_negentropy` - Whether to disable NIP-77 negentropy sync /// * `archive_all` - Accept all repository announcements (GRASP-05) /// * `archive_read_only` - Reject git pushes (read-only archive mode) pub async fn start_with_archive_and_sync( port: u16, bootstrap_relay_url: Option, disable_negentropy: bool, archive_all: bool, archive_read_only: bool, ) -> Self { let bind_address = format!("127.0.0.1:{}", port); let url = format!("ws://127.0.0.1:{}", port); // Create temporary directory for git repositories let git_data_dir = tempfile::tempdir().expect("Failed to create temporary git data directory"); // Use the built binary directly (faster than cargo run) let binary_path = std::env::current_exe() .expect("Failed to get current exe") .parent() .expect("Failed to get parent dir") .parent() .expect("Failed to get grandparent dir") .join("ngit-grasp"); // Generate a test owner npub (using a random keypair) let test_keys = nostr_sdk::Keys::generate(); let test_npub = test_keys .public_key() .to_bech32() .expect("Failed to generate test npub"); // Start the relay process let mut cmd = Command::new(&binary_path); cmd.env("NGIT_BIND_ADDRESS", &bind_address) .env("NGIT_DOMAIN", &bind_address) // Set domain to match bind address .env("NGIT_GIT_DATA_PATH", git_data_dir.path()) .env("NGIT_DATABASE_BACKEND", "memory") // Force in-memory database for isolation .env("NGIT_OWNER_NPUB", &test_npub) .env("NGIT_TEST", "1") // Enable test mode: fast timers (200ms batch window, 200ms purgatory sync) .env("NGIT_SYNC_STARTUP_DELAY_SECS", "0") // No startup delay for faster tests .env("NGIT_SYNC_STARTUP_JITTER_MS", "0") // No jitter for tests .env("NGIT_SYNC_DISCONNECT_CHECK_INTERVAL_SECS", "1") // Fast reconnect attempts for tests .env("NGIT_SYNC_BASE_BACKOFF_SECS", "1") // Fast backoff for tests (1s instead of 5s default) .env( "RUST_LOG", std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string()), ) // Use RUST_LOG from environment or default to info .stdout( std::fs::OpenOptions::new() .create(true) .append(true) .open(format!("/tmp/relay-{}.log", port)) .map(Stdio::from) .unwrap_or(Stdio::null()), ) .stderr(Stdio::inherit()); // Inherit stderr for test output // Add bootstrap relay URL if provided if let Some(ref bootstrap_url) = bootstrap_relay_url { cmd.env("NGIT_SYNC_BOOTSTRAP_RELAY_URL", bootstrap_url); } // Add negentropy disable flag if requested if disable_negentropy { cmd.env("NGIT_SYNC_DISABLE_NEGENTROPY", "true"); } // Add archive configuration if requested if archive_all { cmd.env("NGIT_ARCHIVE_ALL", "true"); } if archive_read_only { cmd.env("NGIT_ARCHIVE_READ_ONLY", "true"); } let process = cmd.spawn().expect("Failed to start relay process"); // Store git data path for test assertions let git_data_path = git_data_dir.path().to_path_buf(); let relay = Self { process, url, port, _git_data_dir: git_data_dir, git_data_path, }; // Wait for relay to be ready relay.wait_for_ready().await; relay } /// Get the relay WebSocket URL pub fn url(&self) -> &str { &self.url } /// Get the relay domain (host:port) pub fn domain(&self) -> String { format!("127.0.0.1:{}", self.port) } /// Get the git data directory path /// /// This is useful for test assertions that need to verify /// git repositories were created correctly. pub fn git_data_path(&self) -> &PathBuf { &self.git_data_path } /// Wait for the relay to be ready to accept connections async fn wait_for_ready(&self) { let max_attempts = 50; // 5 seconds total let delay = Duration::from_millis(100); for attempt in 0..max_attempts { // Try to connect to the relay match tokio::net::TcpStream::connect(format!("127.0.0.1:{}", self.port)).await { Ok(_) => { // Connection successful, relay is ready // Give it a tiny bit more time to fully initialize sleep(Duration::from_millis(100)).await; return; } Err(_) => { if attempt == max_attempts - 1 { panic!("Relay failed to start after {} attempts", max_attempts); } sleep(delay).await; } } } } /// Stop the relay pub async fn stop(mut self) { // Kill the process (gracefully if possible) let _ = self.process.kill(); // Wait a bit for graceful shutdown sleep(Duration::from_millis(100)).await; // Force kill if still running let _ = self.process.kill(); let _ = self.process.wait(); } /// Find a free port to use for testing pub fn find_free_port() -> u16 { use std::net::TcpListener; // Bind to port 0 to get a random free port 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 address") .port(); // Drop the listener to free the port drop(listener); port } } impl Drop for TestRelay { fn drop(&mut self) { // Ensure process is killed when TestRelay is dropped let _ = self.process.kill(); let _ = self.process.wait(); } } #[cfg(test)] mod tests { use super::*; #[test] fn test_find_free_port() { let port = TestRelay::find_free_port(); assert!(port > 0); // Port is u16, so it's always < 65536 } }