Files
ngit-grasp/tests/common/relay.rs
DanConwayDev ee17a1703b feat: Add git repository archival and background cleanup
Archive git repositories before deletion:
- Create tar.gz archives in .archive/<npub>/<identifier>-<timestamp>.tar.gz
- Store metadata JSON with deletion info and expiry timestamp
- Archive git data BEFORE moving events to holding database
- Uses flate2 for gzip compression and tar crate for archive creation
- Graceful error handling (deletion continues even if archival fails)

Background cleanup task:
- Spawned as tokio task in main.rs
- Runs on startup (catch-up for offline periods)
- Runs at configurable interval (default: 24 hours)
- Deletes expired events from holding database
- Removes archive tar.gz files and metadata JSON
- Tracks disk space reclamation in CleanupResult

Archive metadata structure:
- Deletion timestamp and event ID
- Maintainer pubkey and repository identifier
- Archive file path for cleanup coordination
- Expiry timestamp (deletion_ts + retention_secs)

Tests: 21 tests covering:
- Archive unit tests (8 tests)
  - Archive creation and compression
  - Extraction and integrity verification
  - Metadata serialization
  - Error handling (missing directories, corrupt data)
- Cleanup unit tests (5 tests)
  - Expired event deletion
  - Archive file cleanup
  - Missing file handling
  - CleanupResult statistics
- Integration tests (8 tests)
  - End-to-end archival workflow
  - Background cleanup with short retention (5 seconds)
  - Startup cleanup (pre-existing expired data)
  - Disk space reclamation verification
  - Multiple expired events
  - Preservation of non-expired events
2026-01-15 10:33:53 +00:00

349 lines
11 KiB
Rust

//! Test relay fixture
//!
//! Provides automatic relay lifecycle management for integration tests.
use nostr_sdk::ToBech32;
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,
}
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<String>,
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<String>) -> 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<String>) -> Self {
Self::start_with_full_options(Self::find_free_port(), bootstrap_relay_url, true).await
}
/// Start relay with custom retention period
///
/// This is useful for testing deletion and archival with short retention periods.
///
/// # Arguments
/// * `retention_secs` - Archive retention period in seconds
///
/// # Example
///
/// ```no_run
/// use common::TestRelay;
///
/// #[tokio::test]
/// async fn test_deletion_with_short_retention() {
/// let relay = TestRelay::start_with_retention(5).await;
/// // ... test deletion with 5-second retention ...
/// relay.stop().await;
/// }
/// ```
pub async fn start_with_retention(retention_secs: u64) -> Self {
Self::start_with_extended_options(
Self::find_free_port(),
None,
false,
Some(retention_secs),
None,
)
.await
}
/// Start relay with custom retention and cleanup interval
///
/// This is useful for testing deletion and archival with short retention periods
/// and fast cleanup cycles.
///
/// # Arguments
/// * `retention_secs` - Archive retention period in seconds
/// * `cleanup_interval_secs` - How often to run cleanup (in seconds)
///
/// # Example
///
/// ```no_run
/// use common::TestRelay;
///
/// #[tokio::test]
/// async fn test_deletion_with_fast_cleanup() {
/// let relay = TestRelay::start_with_retention_and_cleanup(5, 2).await;
/// // ... test deletion with 5-second retention and 2-second cleanup interval ...
/// relay.stop().await;
/// }
/// ```
pub async fn start_with_retention_and_cleanup(
retention_secs: u64,
cleanup_interval_secs: u64,
) -> Self {
Self::start_with_extended_options(
Self::find_free_port(),
None,
false,
Some(retention_secs),
Some(cleanup_interval_secs),
)
.await
}
/// Start relay with options (internal, maintains backward compatibility)
async fn start_with_options(port: u16, bootstrap_relay_url: Option<String>) -> 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<String>,
disable_negentropy: bool,
) -> Self {
Self::start_with_extended_options(port, bootstrap_relay_url, disable_negentropy, None, None)
.await
}
/// Start relay with extended options (internal)
async fn start_with_extended_options(
port: u16,
bootstrap_relay_url: Option<String>,
disable_negentropy: bool,
retention_secs: Option<u64>,
cleanup_interval_secs: Option<u64>,
) -> 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_SYNC_BATCH_WINDOW_MS", "200") // Fast batch window for tests (200ms instead of 5s default)
.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(Stdio::null()) // Suppress stdout for cleaner test output
.stderr(Stdio::null()); // Suppress stderr for cleaner 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 custom retention period if provided
if let Some(retention) = retention_secs {
cmd.env("NGIT_ARCHIVE_RETENTION_SECS", retention.to_string());
}
// Add custom cleanup interval if provided
if let Some(cleanup_interval) = cleanup_interval_secs {
cmd.env(
"NGIT_ARCHIVE_CLEANUP_INTERVAL_SECS",
cleanup_interval.to_string(),
);
}
let process = cmd.spawn().expect("Failed to start relay process");
let relay = Self { process, url, port };
// 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)
}
/// 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
}
}