mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-05 15:08:24 +00:00
Paired setup calls constructed separate announcements, state events and Git commits for the same owner/identifier. Discovery could exchange those replaceable events while setup waited for an exact ID, making fixture identity depend on timestamp and event-ID ordering. Prepare one RepositoryFixture and install its unchanged signed events and Git data on each relay. Migrate all sixteen paired scenarios in descendant, discovery, live-sync, metrics and tag-variation tests. Historic tests retain the target listener reservation before seeding the source, so the original announcement can list both endpoints without starting target sync early. Keep exact-ID visibility checks, their deadlines and failure diagnostics. Add a wire-level regression checking both event IDs and remote Git refs after repeated installation. Production replacement policy, dependencies, intentional revision tests and concurrency remain unchanged. Validation: rustfmt and whitespace checks pass; paired setup callers audited. The new regression and migrated scenarios await CI execution, per the request to keep Cargo tests on CI/host. The preceding diagnostic CI revision stopped at a GitHub archive HTTP 504 before Rust tests and gave no further evidence about the original setup failure. Assisted-by: Codex (GPT-6)
1731 lines
59 KiB
Rust
1731 lines
59 KiB
Rust
//! Proactive Sync Test Helpers
|
|
//!
|
|
//! Provides utilities for testing ngit-grasp's proactive sync functionality:
|
|
//! - `TestClient` - Client wrapper with built-in retry logic
|
|
//! - Event builders for Layer 2 (kind 1621) and Layer 3 (kinds 1, 1111) events
|
|
//! - Assertion helpers that return bool (non-panicking)
|
|
//!
|
|
//! # nostr-sdk 0.43 API Notes
|
|
//! - Use field access: `event.id`, `event.tags`, `event.tags.iter()`
|
|
//! - Use `Tag::custom("name", vec![...])` syntax
|
|
//! - Use `EventBuilder::new(kind, content).tags(tags)` syntax
|
|
|
|
use std::collections::HashMap;
|
|
use std::time::Duration;
|
|
|
|
use nostr_sdk::prelude::*;
|
|
|
|
use super::port::{self, PortReservation, UnavailableEndpoint};
|
|
use super::relay::TestRelay;
|
|
|
|
const DESCENDANT_LIVE_LOG: &str = "Installed priority-bounded auxiliary live coverage";
|
|
|
|
pub fn descendant_live_generation(relay: &TestRelay) -> usize {
|
|
std::fs::read_to_string(relay.log_path())
|
|
.unwrap_or_default()
|
|
.matches(DESCENDANT_LIVE_LOG)
|
|
.count()
|
|
}
|
|
|
|
pub async fn wait_for_new_descendant_live_generation(
|
|
relay: &TestRelay,
|
|
previous: usize,
|
|
timeout: Duration,
|
|
) {
|
|
let deadline = tokio::time::Instant::now() + timeout;
|
|
loop {
|
|
if descendant_live_generation(relay) > previous {
|
|
return;
|
|
}
|
|
assert!(
|
|
tokio::time::Instant::now() < deadline,
|
|
"descendant live coverage was not installed before the deadline"
|
|
);
|
|
tokio::task::yield_now().await;
|
|
}
|
|
}
|
|
|
|
pub async fn wait_for_full_repo_live_coverage(relay: &TestRelay, timeout: Duration) {
|
|
let deadline = tokio::time::Instant::now() + timeout;
|
|
loop {
|
|
let log = std::fs::read_to_string(relay.log_path()).unwrap_or_default();
|
|
let installed = log
|
|
.rfind("new_full_repos=1")
|
|
.is_some_and(|offset| log[offset..].contains("Extended core live coverage"));
|
|
if installed {
|
|
return;
|
|
}
|
|
assert!(
|
|
tokio::time::Instant::now() < deadline,
|
|
"full-repository live coverage was not installed before the deadline"
|
|
);
|
|
tokio::task::yield_now().await;
|
|
}
|
|
}
|
|
|
|
/// Wait until some line of a relay subprocess log satisfies the predicate.
|
|
pub async fn wait_for_log_line<F>(
|
|
log_path: &std::path::Path,
|
|
timeout: Duration,
|
|
predicate: F,
|
|
) -> bool
|
|
where
|
|
F: Fn(&str) -> bool,
|
|
{
|
|
let deadline = tokio::time::Instant::now() + timeout;
|
|
loop {
|
|
if let Ok(content) = tokio::fs::read_to_string(log_path).await {
|
|
if content.lines().any(&predicate) {
|
|
return true;
|
|
}
|
|
}
|
|
if tokio::time::Instant::now() >= deadline {
|
|
return false;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
}
|
|
}
|
|
|
|
// NOTE: Using rust-nostr Kind variants:
|
|
// - Kind::GitIssue.as_u16() -> Kind::GitIssue (1621)
|
|
// - Kind::Comment.as_u16() -> Kind::Comment (1111)
|
|
// - Kind::GitRepoAnnouncement.as_u16() -> Kind::GitRepoAnnouncement (30617)
|
|
|
|
/// Test client with built-in retry logic for connect and send operations.
|
|
///
|
|
/// Wraps nostr-sdk Client with automatic retry handling suitable for
|
|
/// integration tests where connections may take time to establish.
|
|
pub struct TestClient {
|
|
client: Client,
|
|
relay_url: String,
|
|
keys: Keys,
|
|
}
|
|
|
|
impl TestClient {
|
|
/// Create a new TestClient and connect to the specified relay.
|
|
///
|
|
/// Uses retry logic: up to 30 attempts with 100ms delay between each.
|
|
///
|
|
/// # Arguments
|
|
/// * `relay_url` - WebSocket URL of the relay (e.g., "ws://127.0.0.1:8080")
|
|
/// * `keys` - Nostr keys for signing events
|
|
///
|
|
/// # Returns
|
|
/// * `Ok(TestClient)` on successful connection
|
|
/// * `Err(String)` if connection fails after all retries
|
|
pub async fn new(relay_url: &str, keys: Keys) -> Result<Self, String> {
|
|
let client = Client::builder()
|
|
.authenticator(SignerAuthenticator::new(keys.clone()))
|
|
.build();
|
|
|
|
client
|
|
.add_relay(relay_url)
|
|
.await
|
|
.map_err(|e| format!("Failed to add relay: {}", e))?;
|
|
|
|
let test_client = Self {
|
|
client,
|
|
relay_url: relay_url.to_string(),
|
|
keys,
|
|
};
|
|
|
|
test_client.connect().await?;
|
|
|
|
Ok(test_client)
|
|
}
|
|
|
|
/// Connect to the relay with retry logic.
|
|
///
|
|
/// Attempts connection up to 30 times with 100ms delays (3 seconds total).
|
|
pub async fn connect(&self) -> Result<(), String> {
|
|
self.client.connect().await;
|
|
|
|
// Wait for connection with retries (matching existing pattern)
|
|
for attempt in 0..30 {
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
let relays = self.client.relays().await;
|
|
if relays.values().any(|r| r.status().is_connected()) {
|
|
return Ok(());
|
|
}
|
|
if attempt == 29 {
|
|
return Err(format!(
|
|
"Failed to connect to relay {} after 3 seconds",
|
|
self.relay_url
|
|
));
|
|
}
|
|
}
|
|
|
|
Err("Connection loop exited unexpectedly".to_string())
|
|
}
|
|
|
|
/// Send an event with bounded retry for transient transport failures.
|
|
///
|
|
/// Attempts to send up to 3 times with short backoff (200ms, then 400ms),
|
|
/// reconnecting between attempts. Only errors that leave delivery in doubt
|
|
/// (disconnection, transport failure, OK timeout) are retried; a relay
|
|
/// `OK false` rejection is deterministic and fails immediately so tests
|
|
/// cannot mask write-policy regressions.
|
|
///
|
|
/// # Arguments
|
|
/// * `event` - The signed event to send
|
|
///
|
|
/// # Returns
|
|
/// * `Ok(EventId)` on successful send
|
|
/// * `Err(String)` on relay rejection or after all attempts fail
|
|
pub async fn send_event(&self, event: &Event) -> Result<EventId, String> {
|
|
let delays = [0, 200, 400]; // Backoff in ms
|
|
let mut last_error = String::new();
|
|
|
|
for (attempt, delay_ms) in delays.iter().enumerate() {
|
|
if *delay_ms > 0 {
|
|
tokio::time::sleep(Duration::from_millis(*delay_ms)).await;
|
|
}
|
|
|
|
// Send through the single tracked relay rather than the pool so
|
|
// the SDK error kind survives and OK-false can be told apart from
|
|
// transport failures.
|
|
let relay = self
|
|
.client
|
|
.relay(self.relay_url.as_str())
|
|
.await
|
|
.map_err(|e| format!("Failed to look up relay {}: {}", self.relay_url, e))?
|
|
.ok_or_else(|| format!("Relay {} missing from client pool", self.relay_url))?;
|
|
|
|
match relay.send_event(event).await {
|
|
Ok(output) => return Ok(*output.id()),
|
|
Err(e) if e.kind() == ErrorKind::Rejected => {
|
|
return Err(format!("Relay rejected event {}: {}", event.id, e));
|
|
}
|
|
Err(e) => {
|
|
eprintln!(" Send attempt {} - transient error: {}", attempt + 1, e);
|
|
last_error = e.to_string();
|
|
// Re-establish the connection before the next attempt.
|
|
let _ = self.connect().await;
|
|
}
|
|
}
|
|
}
|
|
|
|
Err(format!(
|
|
"Failed to send event {} after {} attempts: {}",
|
|
event.id,
|
|
delays.len(),
|
|
last_error
|
|
))
|
|
}
|
|
|
|
/// Get a reference to the keys used by this client.
|
|
pub fn keys(&self) -> &Keys {
|
|
&self.keys
|
|
}
|
|
|
|
/// Disconnect from the relay.
|
|
pub async fn disconnect(self) {
|
|
self.client.disconnect().await;
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Event Builders
|
|
// ============================================================================
|
|
|
|
/// Build a Layer 2 issue event (kind 1618) with a/A/q tags referencing a repository.
|
|
///
|
|
/// Creates an issue event that references the specified repository coordinate.
|
|
/// Supports different tag types for comprehensive Layer 2 filter testing.
|
|
///
|
|
/// # Arguments
|
|
/// * `keys` - Keys for signing the event
|
|
/// * `repo_coord` - Repository coordinate (format: "30617:pubkey_hex:identifier")
|
|
/// * `title` - Issue title (used as content)
|
|
///
|
|
/// # Tag Types
|
|
/// Uses lowercase 'a' tag by default. For other tag variations, see:
|
|
/// - `build_layer2_issue_with_uppercase_a_tag`
|
|
/// - `build_layer2_issue_with_q_tag`
|
|
///
|
|
/// # Returns
|
|
/// * `Ok(Event)` - Signed event ready to send
|
|
/// * `Err(String)` - If signing fails
|
|
pub fn build_layer2_issue_event(
|
|
keys: &Keys,
|
|
repo_coord: &str,
|
|
title: &str,
|
|
) -> Result<Event, String> {
|
|
build_layer2_issue_with_tag(keys, repo_coord, title, TagVariant::LowercaseA)
|
|
}
|
|
|
|
/// Build a Layer 2 issue with uppercase 'A' tag.
|
|
pub fn build_layer2_issue_with_uppercase_a_tag(
|
|
keys: &Keys,
|
|
repo_coord: &str,
|
|
title: &str,
|
|
) -> Result<Event, String> {
|
|
build_layer2_issue_with_tag(keys, repo_coord, title, TagVariant::UppercaseA)
|
|
}
|
|
|
|
/// Build a Layer 2 issue with 'q' (quote) tag.
|
|
pub fn build_layer2_issue_with_q_tag(
|
|
keys: &Keys,
|
|
repo_coord: &str,
|
|
title: &str,
|
|
) -> Result<Event, String> {
|
|
build_layer2_issue_with_tag(keys, repo_coord, title, TagVariant::QuoteQ)
|
|
}
|
|
|
|
/// Tag variant for Layer 2 events (referencing repo coordinates)
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub enum TagVariant {
|
|
/// Lowercase 'a' tag - standard addressable reference
|
|
LowercaseA,
|
|
/// Uppercase 'A' tag - some clients use this
|
|
UppercaseA,
|
|
/// Quote 'q' tag - NIP-10 quote reference
|
|
QuoteQ,
|
|
}
|
|
|
|
/// Internal helper to build Layer 2 issue with specified tag variant.
|
|
fn build_layer2_issue_with_tag(
|
|
keys: &Keys,
|
|
repo_coord: &str,
|
|
title: &str,
|
|
tag_variant: TagVariant,
|
|
) -> Result<Event, String> {
|
|
let tag = match tag_variant {
|
|
TagVariant::LowercaseA => Tag::custom("a", vec![repo_coord.to_string()]),
|
|
TagVariant::UppercaseA => Tag::custom("A", vec![repo_coord.to_string()]),
|
|
TagVariant::QuoteQ => Tag::custom("q", vec![repo_coord.to_string()]),
|
|
};
|
|
|
|
let tags = vec![tag];
|
|
|
|
EventBuilder::new(Kind::GitIssue, title)
|
|
.tags(tags)
|
|
.finalize(keys)
|
|
.map_err(|e| format!("Failed to sign Layer 2 issue event: {}", e))
|
|
}
|
|
|
|
/// Build a Layer 3 comment event (kinds 1 or 1111) with e/E/q tags referencing an event ID.
|
|
///
|
|
/// Creates a comment/reply event that references the specified parent event ID.
|
|
/// Supports different kinds and tag types for comprehensive Layer 3 filter testing.
|
|
///
|
|
/// # Arguments
|
|
/// * `keys` - Keys for signing the event
|
|
/// * `parent_event_id` - Event ID being referenced (e.g., an issue or patch)
|
|
/// * `content` - Comment content
|
|
/// * `kind` - Event kind (Kind::TextNote for reply, Kind::Comment for NIP-22 comment)
|
|
///
|
|
/// # Tag Types
|
|
/// - For kind 1111: Uses uppercase 'E' tag (NIP-22 style)
|
|
/// - For kind 1: Uses lowercase 'e' tag with "root" marker (NIP-10 style)
|
|
///
|
|
/// # Returns
|
|
/// * `Ok(Event)` - Signed event ready to send
|
|
/// * `Err(String)` - If signing fails
|
|
pub fn build_layer3_comment_event(
|
|
keys: &Keys,
|
|
parent_event_id: &EventId,
|
|
content: &str,
|
|
kind: Kind,
|
|
) -> Result<Event, String> {
|
|
let kind_num = kind.as_u16();
|
|
|
|
// Choose tag based on kind (NIP-22 uses E, NIP-10 style uses e)
|
|
let tag = if kind_num == Kind::Comment.as_u16() {
|
|
// NIP-22 comment: uppercase 'E' tag
|
|
Tag::custom("E", vec![parent_event_id.to_hex()])
|
|
} else {
|
|
// Kind 1 reply: lowercase 'e' tag with root marker (NIP-10)
|
|
Tag::custom(
|
|
"e",
|
|
vec![parent_event_id.to_hex(), "".to_string(), "root".to_string()],
|
|
)
|
|
};
|
|
|
|
let tags = vec![tag];
|
|
|
|
EventBuilder::new(kind, content)
|
|
.tags(tags)
|
|
.finalize(keys)
|
|
.map_err(|e| format!("Failed to sign Layer 3 comment event: {}", e))
|
|
}
|
|
|
|
/// Build a Layer 3 reply (kind 1) with lowercase 'e' tag.
|
|
pub fn build_layer3_reply_with_e_tag(
|
|
keys: &Keys,
|
|
parent_event_id: &EventId,
|
|
content: &str,
|
|
) -> Result<Event, String> {
|
|
let tag = Tag::custom(
|
|
"e",
|
|
vec![parent_event_id.to_hex(), "".to_string(), "root".to_string()],
|
|
);
|
|
|
|
EventBuilder::new(Kind::Custom(1), content)
|
|
.tags(vec![tag])
|
|
.finalize(keys)
|
|
.map_err(|e| format!("Failed to sign Layer 3 reply event: {}", e))
|
|
}
|
|
|
|
/// Build a Layer 3 comment (kind 1111) with uppercase 'E' tag (NIP-22).
|
|
pub fn build_layer3_comment_with_uppercase_e_tag(
|
|
keys: &Keys,
|
|
parent_event_id: &EventId,
|
|
content: &str,
|
|
) -> Result<Event, String> {
|
|
let tag = Tag::custom("E", vec![parent_event_id.to_hex()]);
|
|
|
|
EventBuilder::new(Kind::Comment, content)
|
|
.tags(vec![tag])
|
|
.finalize(keys)
|
|
.map_err(|e| format!("Failed to sign Layer 3 comment event: {}", e))
|
|
}
|
|
|
|
/// Build a Layer 3 quote (kind 1) with 'q' tag.
|
|
pub fn build_layer3_quote_with_q_tag(
|
|
keys: &Keys,
|
|
parent_event_id: &EventId,
|
|
content: &str,
|
|
) -> Result<Event, String> {
|
|
let tag = Tag::custom("q", vec![parent_event_id.to_hex()]);
|
|
|
|
EventBuilder::new(Kind::Custom(1), content)
|
|
.tags(vec![tag])
|
|
.finalize(keys)
|
|
.map_err(|e| format!("Failed to sign Layer 3 quote event: {}", e))
|
|
}
|
|
|
|
// ============================================================================
|
|
// Repository Announcement Helper
|
|
// ============================================================================
|
|
|
|
/// Create a valid repository announcement event for testing sync.
|
|
///
|
|
/// This creates a kind 30617 event with required clone and relays tags.
|
|
/// The event lists all provided domains so it will be accepted by each
|
|
/// relay's write policy.
|
|
///
|
|
/// # Arguments
|
|
/// * `keys` - Keys for signing
|
|
/// * `domains` - Slice of domain strings (e.g., "127.0.0.1:8080")
|
|
/// * `identifier` - Repository identifier (d-tag)
|
|
///
|
|
/// # Returns
|
|
/// A signed repository announcement event ready to send.
|
|
pub fn create_repo_announcement(keys: &Keys, domains: &[&str], identifier: &str) -> Event {
|
|
// Get npub for the clone URL path (format: /<npub>/<identifier>.git)
|
|
let npub = keys
|
|
.public_key()
|
|
.to_bech32()
|
|
.expect("Failed to convert public key to npub");
|
|
|
|
// Build clone URLs for all domains (with npub and .git suffix)
|
|
let clone_urls: Vec<String> = domains
|
|
.iter()
|
|
.map(|d| format!("http://{}/{}/{}.git", d, npub, identifier))
|
|
.collect();
|
|
|
|
// Build relay URLs for all domains
|
|
let relay_urls: Vec<String> = domains.iter().map(|d| format!("ws://{}", d)).collect();
|
|
|
|
// Build tags for repository announcement
|
|
let tags = vec![
|
|
Tag::identifier(identifier),
|
|
Tag::custom("clone", clone_urls),
|
|
Tag::custom("relays", relay_urls),
|
|
];
|
|
|
|
EventBuilder::new(Kind::GitRepoAnnouncement, "Repository state")
|
|
.tags(tags)
|
|
.finalize(keys)
|
|
.expect("Failed to sign repo announcement")
|
|
}
|
|
|
|
// ============================================================================
|
|
// Sync Connection Helpers
|
|
// ============================================================================
|
|
|
|
/// Wait for a sync connection to be established on the syncing relay.
|
|
///
|
|
/// Polls the relay's metrics endpoint to check for active sync connections.
|
|
/// This is more reliable than using fixed sleeps, as it verifies the actual
|
|
/// connection state before proceeding with test assertions.
|
|
///
|
|
/// # Arguments
|
|
/// * `syncing_relay_url` - WebSocket URL of the relay that is syncing (e.g., "ws://127.0.0.1:8080")
|
|
/// * `expected_connections` - Expected number of sync connections (typically 1 for single bootstrap)
|
|
/// * `timeout` - Maximum time to wait for connections to be established
|
|
///
|
|
/// # Returns
|
|
/// * `Ok(())` - Connections established within timeout
|
|
/// * `Err(String)` - Timeout waiting for connections, or other error
|
|
///
|
|
/// # Example
|
|
/// ```ignore
|
|
/// // After starting relay_b with sync from relay_a
|
|
/// let relay_b = TestRelay::start_with_sync(Some(relay_a.url().into())).await;
|
|
///
|
|
/// // Wait for sync connection to be established
|
|
/// wait_for_sync_connection(relay_b.url(), 1, Duration::from_secs(5)).await
|
|
/// .expect("Sync connection should be established");
|
|
///
|
|
/// // Now proceed with test - sync connection is verified
|
|
/// ```
|
|
pub async fn wait_for_sync_connection(
|
|
syncing_relay_url: &str,
|
|
expected_connections: usize,
|
|
timeout: Duration,
|
|
) -> Result<(), String> {
|
|
let wait = async {
|
|
loop {
|
|
if let Ok(metrics) = fetch_metrics(syncing_relay_url).await {
|
|
if check_sync_connections_in_metrics(&metrics, expected_connections) {
|
|
return;
|
|
}
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
}
|
|
};
|
|
tokio::time::timeout(timeout, wait).await.map_err(|_| format!(
|
|
"Timeout waiting for {expected_connections} sync connection(s) on {syncing_relay_url} after {timeout:?}"
|
|
))
|
|
}
|
|
|
|
/// Connection attempts and health states do not establish a live connection.
|
|
fn check_sync_connections_in_metrics(metrics: &str, expected: usize) -> bool {
|
|
ParsedMetrics::parse(metrics)
|
|
.relays_connected_total()
|
|
.is_some_and(|connected| connected >= expected as i64)
|
|
}
|
|
|
|
// ============================================================================
|
|
// Assertion Helpers
|
|
// ============================================================================
|
|
|
|
/// Wait for an event to appear on a relay.
|
|
///
|
|
/// Polls the relay for the specified event using the provided filter.
|
|
/// Returns true if found within timeout, false otherwise.
|
|
///
|
|
/// **Important:** This function does NOT panic - it returns a bool to allow
|
|
/// tests to make their own assertions with descriptive error messages.
|
|
///
|
|
/// # Arguments
|
|
/// * `relay_url` - WebSocket URL of the relay to check
|
|
/// * `filter` - Nostr filter to use for querying (should match the expected event)
|
|
/// * `timeout` - Maximum time to wait for the event
|
|
///
|
|
/// # Returns
|
|
/// * `true` - Event matching filter was found
|
|
/// * `false` - Event not found within timeout, or connection failed
|
|
///
|
|
/// # Example
|
|
/// ```ignore
|
|
/// let filter = Filter::new()
|
|
/// .kind(Kind::GitPullRequest)
|
|
/// .author(keys.public_key())
|
|
/// .id(event.id);
|
|
///
|
|
/// let found = wait_for_event_on_relay(relay.url(), filter, Duration::from_secs(3)).await;
|
|
/// assert!(found, "Expected event {} to sync to relay", event.id);
|
|
/// ```
|
|
pub async fn wait_for_event_on_relay(relay_url: &str, filter: Filter, timeout: Duration) -> bool {
|
|
let deadline = tokio::time::Instant::now() + timeout;
|
|
let poll_interval = Duration::from_millis(200);
|
|
|
|
loop {
|
|
// Create a fresh client for each poll attempt (avoids stale connection state)
|
|
let temp_keys = Keys::generate();
|
|
let client = Client::builder()
|
|
.authenticator(SignerAuthenticator::new(temp_keys))
|
|
.build();
|
|
|
|
if client.add_relay(relay_url).await.is_err() {
|
|
if tokio::time::Instant::now() >= deadline {
|
|
return false;
|
|
}
|
|
tokio::time::sleep(poll_interval).await;
|
|
continue;
|
|
}
|
|
|
|
client.connect().await;
|
|
|
|
// Wait for connection
|
|
let mut connected = false;
|
|
for _ in 0..10 {
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
let relays = client.relays().await;
|
|
if relays.values().any(|r| r.status().is_connected()) {
|
|
connected = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if connected {
|
|
// Use a short fetch window — if the event is there, EOSE comes back quickly
|
|
let fetch_timeout = Duration::from_millis(500);
|
|
let result = client
|
|
.fetch_events(filter.clone())
|
|
.timeout(fetch_timeout)
|
|
.await;
|
|
client.disconnect().await;
|
|
|
|
match result {
|
|
Ok(events) if !events.is_empty() => return true,
|
|
_ => {}
|
|
}
|
|
} else {
|
|
client.disconnect().await;
|
|
}
|
|
|
|
if tokio::time::Instant::now() >= deadline {
|
|
return false;
|
|
}
|
|
tokio::time::sleep(poll_interval).await;
|
|
}
|
|
}
|
|
|
|
/// Build repo coordinate string for use in 'a' tags.
|
|
///
|
|
/// Format: `30617:pubkey_hex:identifier`
|
|
///
|
|
/// # Arguments
|
|
/// * `keys` - Keys whose public key will be used
|
|
/// * `identifier` - Repository identifier (d-tag value)
|
|
pub fn repo_coord(keys: &Keys, identifier: &str) -> String {
|
|
format!(
|
|
"{}:{}:{}",
|
|
Kind::GitRepoAnnouncement.as_u16(),
|
|
keys.public_key().to_hex(),
|
|
identifier
|
|
)
|
|
}
|
|
|
|
// ============================================================================
|
|
// Metrics Helpers
|
|
// ============================================================================
|
|
|
|
/// Fetch Prometheus metrics from a relay's `/metrics` endpoint.
|
|
///
|
|
/// Converts the WebSocket URL to HTTP and fetches the metrics endpoint.
|
|
/// Useful for verifying sync-related metrics in tests.
|
|
///
|
|
/// # Arguments
|
|
/// * `relay_url` - WebSocket URL of the relay (e.g., "ws://127.0.0.1:8080")
|
|
///
|
|
/// # Returns
|
|
/// * `Ok(String)` - The metrics text in Prometheus format
|
|
/// * `Err(reqwest::Error)` - If the request fails
|
|
///
|
|
/// # Example
|
|
/// ```ignore
|
|
/// let metrics = fetch_metrics("ws://127.0.0.1:8080").await?;
|
|
/// assert!(metrics.contains("ngit_sync_"));
|
|
/// ```
|
|
pub async fn fetch_metrics(relay_url: &str) -> Result<String, reqwest::Error> {
|
|
reqwest::get(metrics_url(relay_url)).await?.text().await
|
|
}
|
|
|
|
fn metrics_url(relay_url: &str) -> String {
|
|
let http_url = relay_url
|
|
.replacen("wss://", "https://", 1)
|
|
.replacen("ws://", "http://", 1);
|
|
format!("{}/metrics", http_url.trim_end_matches('/'))
|
|
}
|
|
|
|
// ============================================================================
|
|
// Prometheus Metrics Parser
|
|
// ============================================================================
|
|
|
|
/// A single metric value with its labels
|
|
#[derive(Debug, Clone)]
|
|
struct MetricValue {
|
|
labels: HashMap<String, String>,
|
|
value: f64,
|
|
}
|
|
|
|
/// Parsed Prometheus metrics with typed accessors.
|
|
///
|
|
/// Parses Prometheus text format and provides strongly-typed access
|
|
/// to metric values with label filtering support.
|
|
///
|
|
/// # Example
|
|
/// ```ignore
|
|
/// let metrics = ParsedMetrics::parse(text);
|
|
/// let events = metrics.counter("ngit_sync_events_total", &[("source", "live")]);
|
|
/// let connected = metrics.relay_connected("ws://127.0.0.1:8080");
|
|
/// ```
|
|
#[derive(Debug)]
|
|
pub struct ParsedMetrics {
|
|
metrics: HashMap<String, Vec<MetricValue>>,
|
|
}
|
|
|
|
impl ParsedMetrics {
|
|
/// Parse Prometheus text format into structured data
|
|
pub fn parse(text: &str) -> Self {
|
|
let mut metrics = HashMap::new();
|
|
|
|
for line in text.lines() {
|
|
// Skip comments and empty lines
|
|
if line.starts_with('#') || line.trim().is_empty() {
|
|
continue;
|
|
}
|
|
|
|
// Parse metric line: metric_name{label="value"} 123.45
|
|
// Handle both labeled and unlabeled metrics
|
|
if let Some((name_labels, value_str)) = line.rsplit_once(' ') {
|
|
if let Ok(value) = value_str.trim().parse::<f64>() {
|
|
let (name, labels) = Self::parse_name_and_labels(name_labels);
|
|
metrics
|
|
.entry(name.to_string())
|
|
.or_insert_with(Vec::new)
|
|
.push(MetricValue { labels, value });
|
|
}
|
|
}
|
|
}
|
|
|
|
ParsedMetrics { metrics }
|
|
}
|
|
|
|
fn parse_name_and_labels(name_labels: &str) -> (&str, HashMap<String, String>) {
|
|
if let Some(brace_pos) = name_labels.find('{') {
|
|
let name = &name_labels[..brace_pos];
|
|
let labels_str = &name_labels[brace_pos + 1..name_labels.len() - 1];
|
|
let labels = Self::parse_labels(labels_str);
|
|
(name, labels)
|
|
} else {
|
|
(name_labels, HashMap::new())
|
|
}
|
|
}
|
|
|
|
fn parse_labels(labels_str: &str) -> HashMap<String, String> {
|
|
let mut labels = HashMap::new();
|
|
for pair in labels_str.split(',') {
|
|
if let Some((key, value)) = pair.split_once('=') {
|
|
let key = key.trim();
|
|
let value = value.trim().trim_matches('"');
|
|
labels.insert(key.to_string(), value.to_string());
|
|
}
|
|
}
|
|
labels
|
|
}
|
|
|
|
/// Get counter value with optional label matching
|
|
pub fn counter(&self, name: &str, labels: &[(&str, &str)]) -> Option<u64> {
|
|
self.get_metric(name, labels).map(|v| v as u64)
|
|
}
|
|
|
|
/// Get gauge value with optional label matching
|
|
pub fn gauge(&self, name: &str, labels: &[(&str, &str)]) -> Option<i64> {
|
|
self.get_metric(name, labels).map(|v| v as i64)
|
|
}
|
|
|
|
fn get_metric(&self, name: &str, labels: &[(&str, &str)]) -> Option<f64> {
|
|
let values = self.metrics.get(name)?;
|
|
|
|
if labels.is_empty() {
|
|
// No label filtering - return first match
|
|
values.first().map(|v| v.value)
|
|
} else {
|
|
// Find matching labels
|
|
values
|
|
.iter()
|
|
.find(|v| {
|
|
labels.iter().all(|(k, expected)| {
|
|
v.labels
|
|
.get(*k)
|
|
.map(|actual| actual == expected)
|
|
.unwrap_or(false)
|
|
})
|
|
})
|
|
.map(|v| v.value)
|
|
}
|
|
}
|
|
|
|
// Convenience accessors for sync metrics
|
|
|
|
/// Get total events synced (no source categorization)
|
|
pub fn events_synced_total(&self) -> Option<u64> {
|
|
self.counter("ngit_sync_events_synced_total", &[])
|
|
}
|
|
|
|
/// Check if a specific relay is connected
|
|
pub fn relay_connected(&self, relay: &str) -> Option<bool> {
|
|
self.gauge("ngit_sync_relay_connected", &[("relay", relay)])
|
|
.map(|v| v >= 2) // Syncing (2), Connected (3), or ConnectedHistoricSyncFailures (4)
|
|
}
|
|
|
|
/// Get total number of connected relays
|
|
pub fn relays_connected_total(&self) -> Option<i64> {
|
|
self.gauge("ngit_sync_relays_connected_total", &[])
|
|
}
|
|
|
|
/// Get total number of tracked relays
|
|
pub fn relays_tracked_total(&self) -> Option<i64> {
|
|
self.gauge("ngit_sync_relays_tracked_total", &[])
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Metrics Test Harness
|
|
// ============================================================================
|
|
|
|
/// Multi-relay test harness for metrics validation.
|
|
///
|
|
/// Manages multiple source relays and a syncing relay for testing
|
|
/// sync metrics functionality. Uses random ports for all relays
|
|
/// to avoid conflicts.
|
|
///
|
|
/// # Example
|
|
/// ```ignore
|
|
/// let mut harness = MetricsTestHarness::with_sources(2).await;
|
|
/// harness.start_syncing_relay(0).await; // Sync from source[0]
|
|
///
|
|
/// let metrics = harness.get_metrics().await?;
|
|
/// assert_eq!(metrics.relays_connected_total(), Some(1));
|
|
///
|
|
/// harness.stop_all().await;
|
|
/// ```
|
|
pub struct MetricsTestHarness {
|
|
source_relays: Vec<Option<TestRelay>>,
|
|
syncing_relay: Option<TestRelay>,
|
|
unavailable_endpoint: Option<UnavailableEndpoint>,
|
|
}
|
|
|
|
impl MetricsTestHarness {
|
|
/// Start N source relays (uses TestRelay::start with random ports)
|
|
pub async fn with_sources(count: usize) -> Self {
|
|
let mut source_relays = Vec::new();
|
|
for _ in 0..count {
|
|
source_relays.push(Some(TestRelay::start().await));
|
|
}
|
|
|
|
Self {
|
|
source_relays,
|
|
syncing_relay: None,
|
|
unavailable_endpoint: None,
|
|
}
|
|
}
|
|
|
|
/// Get source relay URL
|
|
pub fn source_url(&self, idx: usize) -> &str {
|
|
self.source_relay(idx).url()
|
|
}
|
|
|
|
/// Get source relay domain (for announcement tags)
|
|
pub fn source_domain(&self, idx: usize) -> String {
|
|
self.source_relay(idx).domain()
|
|
}
|
|
|
|
/// Get a reference to a source relay (for advanced test operations)
|
|
pub fn source_relay(&self, idx: usize) -> &TestRelay {
|
|
self.source_relays[idx]
|
|
.as_ref()
|
|
.expect("source relay has been stopped")
|
|
}
|
|
|
|
/// Submit events to a specific source relay
|
|
pub async fn submit_events(&self, source_idx: usize, events: &[Event]) -> Result<(), String> {
|
|
let relay = self.source_relay(source_idx);
|
|
let keys = Keys::generate();
|
|
let client = TestClient::new(relay.url(), keys).await?;
|
|
|
|
for event in events {
|
|
client.send_event(event).await?;
|
|
}
|
|
|
|
client.disconnect().await;
|
|
Ok(())
|
|
}
|
|
|
|
/// Start syncing relay pointing to source[idx]
|
|
pub async fn start_syncing_relay(&mut self, source_idx: usize) {
|
|
let source_url = self.source_relay(source_idx).url().to_string();
|
|
self.syncing_relay = Some(TestRelay::start_with_sync(Some(source_url)).await);
|
|
}
|
|
|
|
/// Start syncing relay on a caller-reserved port pointing to source[idx].
|
|
///
|
|
/// The caller holds the [`PortReservation`] until they pass it in;
|
|
/// while it's held, no other parallel test in this process can be
|
|
/// handed the same port. See [`crate::common::port`] for why this
|
|
/// matters.
|
|
pub async fn start_syncing_relay_on_reservation(
|
|
&mut self,
|
|
source_idx: usize,
|
|
reservation: PortReservation,
|
|
) {
|
|
let source_url = self.source_relay(source_idx).url().to_string();
|
|
self.syncing_relay = Some(
|
|
TestRelay::start_on_reservation_with_options(reservation, Some(source_url), false)
|
|
.await,
|
|
);
|
|
}
|
|
|
|
/// Start syncing against an owned endpoint that closes every connection.
|
|
pub async fn start_syncing_relay_to_nowhere(&mut self) {
|
|
let endpoint = UnavailableEndpoint::new();
|
|
let nowhere_url = format!("ws://127.0.0.1:{}", endpoint.port());
|
|
self.unavailable_endpoint = Some(endpoint);
|
|
self.syncing_relay = Some(TestRelay::start_with_sync(Some(nowhere_url)).await);
|
|
}
|
|
|
|
/// Stop a source relay
|
|
pub async fn stop_source(&mut self, source_idx: usize) {
|
|
if let Some(relay) = self.source_relays[source_idx].take() {
|
|
relay.stop().await;
|
|
}
|
|
}
|
|
|
|
/// Fetch and parse metrics from syncing relay
|
|
pub async fn get_metrics(&self) -> Result<ParsedMetrics, String> {
|
|
let relay = self
|
|
.syncing_relay
|
|
.as_ref()
|
|
.ok_or_else(|| "Syncing relay not started".to_string())?;
|
|
|
|
let metrics_text = fetch_metrics(relay.url())
|
|
.await
|
|
.map_err(|e| format!("Failed to fetch metrics: {}", e))?;
|
|
|
|
Ok(ParsedMetrics::parse(&metrics_text))
|
|
}
|
|
|
|
/// Get the syncing relay URL (for metrics with relay URL labels)
|
|
pub fn syncing_relay_url(&self) -> Option<&str> {
|
|
self.syncing_relay.as_ref().map(|r| r.url())
|
|
}
|
|
|
|
/// Stop all relays
|
|
pub async fn stop_all(mut self) {
|
|
if let Some(relay) = self.syncing_relay.take() {
|
|
relay.stop().await;
|
|
}
|
|
for relay in self.source_relays.drain(..).flatten() {
|
|
relay.stop().await;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn metrics_url_preserves_scheme_and_base_path() {
|
|
assert_eq!(
|
|
metrics_url("ws://127.0.0.1:1234/"),
|
|
"http://127.0.0.1:1234/metrics"
|
|
);
|
|
assert_eq!(
|
|
metrics_url("wss://example.test/relay/"),
|
|
"https://example.test/relay/metrics"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn connection_readiness_ignores_attempts_and_health() {
|
|
assert!(!check_sync_connections_in_metrics("ngit_sync_connection_attempts_total 5\nngit_sync_health 1\nngit_sync_relays_connected_total 0", 1));
|
|
assert!(check_sync_connections_in_metrics(
|
|
"ngit_sync_relays_connected_total 2",
|
|
2
|
|
));
|
|
assert!(!check_sync_connections_in_metrics(
|
|
"ngit_sync_relays_connected_total 1",
|
|
2
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn test_repo_coord_format() {
|
|
let keys = Keys::generate();
|
|
let coord = repo_coord(&keys, "test-repo");
|
|
|
|
assert!(coord.starts_with("30617:"));
|
|
assert!(coord.ends_with(":test-repo"));
|
|
assert_eq!(coord.split(':').count(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_layer2_issue_event() {
|
|
let keys = Keys::generate();
|
|
let coord = repo_coord(&keys, "my-repo");
|
|
|
|
let event =
|
|
build_layer2_issue_event(&keys, &coord, "Test Issue").expect("Should create event");
|
|
|
|
// nostr-sdk 0.43: use field access
|
|
assert_eq!(event.kind.as_u16(), Kind::GitIssue.as_u16());
|
|
|
|
// Check the tag exists
|
|
let has_a_tag = event.tags.iter().any(|tag| {
|
|
let slice = tag.as_slice();
|
|
slice.first().is_some_and(|t| t == "a")
|
|
});
|
|
assert!(has_a_tag, "Event should have 'a' tag");
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_layer2_issue_with_uppercase_a() {
|
|
let keys = Keys::generate();
|
|
let coord = repo_coord(&keys, "my-repo");
|
|
|
|
let event = build_layer2_issue_with_uppercase_a_tag(&keys, &coord, "Test Issue")
|
|
.expect("Should create event");
|
|
|
|
let has_upper_a_tag = event.tags.iter().any(|tag| {
|
|
let slice = tag.as_slice();
|
|
slice.first().is_some_and(|t| t == "A")
|
|
});
|
|
assert!(has_upper_a_tag, "Event should have 'A' tag");
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_layer2_issue_with_q_tag() {
|
|
let keys = Keys::generate();
|
|
let coord = repo_coord(&keys, "my-repo");
|
|
|
|
let event = build_layer2_issue_with_q_tag(&keys, &coord, "Test Issue")
|
|
.expect("Should create event");
|
|
|
|
let has_q_tag = event.tags.iter().any(|tag| {
|
|
let slice = tag.as_slice();
|
|
slice.first().is_some_and(|t| t == "q")
|
|
});
|
|
assert!(has_q_tag, "Event should have 'q' tag");
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_layer3_comment_kind_1111() {
|
|
let keys = Keys::generate();
|
|
let parent_id = EventId::from_byte_array([0; 32]);
|
|
|
|
let event = build_layer3_comment_event(&keys, &parent_id, "Test comment", Kind::Comment)
|
|
.expect("Should create event");
|
|
|
|
assert_eq!(event.kind.as_u16(), Kind::Comment.as_u16());
|
|
|
|
// NIP-22 comment should have uppercase 'E' tag
|
|
let has_e_tag = event.tags.iter().any(|tag| {
|
|
let slice = tag.as_slice();
|
|
slice.first().is_some_and(|t| t == "E")
|
|
});
|
|
assert!(has_e_tag, "Kind 1111 event should have 'E' tag");
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_layer3_comment_kind_1() {
|
|
let keys = Keys::generate();
|
|
let parent_id = EventId::from_byte_array([0; 32]);
|
|
|
|
let event = build_layer3_comment_event(&keys, &parent_id, "Test reply", Kind::Custom(1))
|
|
.expect("Should create event");
|
|
|
|
assert_eq!(event.kind.as_u16(), 1);
|
|
|
|
// Kind 1 reply should have lowercase 'e' tag with root marker
|
|
let has_e_tag = event.tags.iter().any(|tag| {
|
|
let slice = tag.as_slice();
|
|
slice.first().is_some_and(|t| t == "e")
|
|
});
|
|
assert!(has_e_tag, "Kind 1 event should have 'e' tag");
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_layer3_reply_with_e_tag() {
|
|
let keys = Keys::generate();
|
|
let parent_id = EventId::from_byte_array([0; 32]);
|
|
|
|
let event = build_layer3_reply_with_e_tag(&keys, &parent_id, "Reply content")
|
|
.expect("Should create event");
|
|
|
|
assert_eq!(event.kind.as_u16(), 1);
|
|
|
|
let has_e_tag = event.tags.iter().any(|tag| {
|
|
let slice = tag.as_slice();
|
|
slice.first().is_some_and(|t| t == "e") && slice.get(3).is_some_and(|m| m == "root")
|
|
});
|
|
assert!(has_e_tag, "Should have 'e' tag with root marker");
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_layer3_comment_with_uppercase_e() {
|
|
let keys = Keys::generate();
|
|
let parent_id = EventId::from_byte_array([0; 32]);
|
|
|
|
let event = build_layer3_comment_with_uppercase_e_tag(&keys, &parent_id, "Comment content")
|
|
.expect("Should create event");
|
|
|
|
assert_eq!(event.kind.as_u16(), Kind::Comment.as_u16());
|
|
|
|
let has_upper_e_tag = event.tags.iter().any(|tag| {
|
|
let slice = tag.as_slice();
|
|
slice.first().is_some_and(|t| t == "E")
|
|
});
|
|
assert!(has_upper_e_tag, "Should have uppercase 'E' tag");
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_layer3_quote_with_q() {
|
|
let keys = Keys::generate();
|
|
let parent_id = EventId::from_byte_array([0; 32]);
|
|
|
|
let event = build_layer3_quote_with_q_tag(&keys, &parent_id, "Quote content")
|
|
.expect("Should create event");
|
|
|
|
assert_eq!(event.kind.as_u16(), 1);
|
|
|
|
let has_q_tag = event.tags.iter().any(|tag| {
|
|
let slice = tag.as_slice();
|
|
slice.first().is_some_and(|t| t == "q")
|
|
});
|
|
assert!(has_q_tag, "Should have 'q' tag");
|
|
}
|
|
|
|
// ========================================================================
|
|
// ParsedMetrics Tests
|
|
// ========================================================================
|
|
|
|
#[test]
|
|
fn test_parse_counter_with_labels() {
|
|
let text = r#"ngit_sync_events_total{source="live"} 5"#;
|
|
let metrics = ParsedMetrics::parse(text);
|
|
assert_eq!(
|
|
metrics.counter("ngit_sync_events_total", &[("source", "live")]),
|
|
Some(5)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_gauge_without_labels() {
|
|
let text = r#"ngit_sync_relays_tracked_total 3"#;
|
|
let metrics = ParsedMetrics::parse(text);
|
|
assert_eq!(
|
|
metrics.gauge("ngit_sync_relays_tracked_total", &[]),
|
|
Some(3)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_empty_metrics() {
|
|
let metrics = ParsedMetrics::parse("");
|
|
assert_eq!(metrics.counter("nonexistent", &[]), None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_metric_with_relay_url_label() {
|
|
let text = r#"ngit_sync_relay_connected{relay="ws://127.0.0.1:12345"} 3"#;
|
|
let metrics = ParsedMetrics::parse(text);
|
|
assert_eq!(metrics.relay_connected("ws://127.0.0.1:12345"), Some(true));
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Unified Sync Test Helper
|
|
// ============================================================================
|
|
|
|
/// Result from running a sync test setup
|
|
///
|
|
/// Holds all fixtures needed for making assertions in sync tests.
|
|
/// Returned by [`run_sync_test`] after setting up the test environment.
|
|
pub struct SyncTestResult {
|
|
pub source_relay: TestRelay,
|
|
pub syncing_relay: TestRelay,
|
|
pub maintainer_keys: Keys,
|
|
pub repo_coord: String,
|
|
// Keep SmartGitServer alive for the test duration
|
|
_git_server: Option<super::git_server::SmartGitServer>,
|
|
// Keep temp dir alive for the test duration
|
|
_git_temp_dir: Option<tempfile::TempDir>,
|
|
}
|
|
|
|
/// Helper to send an event to a relay
|
|
///
|
|
/// Creates a temporary client, sends the event, and disconnects.
|
|
pub async fn send_to_relay(relay: &TestRelay, event: &Event) -> Result<(), String> {
|
|
let temp_keys = Keys::generate();
|
|
let client = TestClient::new(relay.url(), temp_keys).await?;
|
|
client.send_event(event).await?;
|
|
client.disconnect().await;
|
|
Ok(())
|
|
}
|
|
|
|
/// Helper to send an event to a relay by URL
|
|
///
|
|
/// Creates a temporary client, sends the event, and disconnects.
|
|
pub async fn send_to_relay_url(relay_url: &str, event: &Event) -> Result<(), String> {
|
|
let temp_keys = Keys::generate();
|
|
let client = TestClient::new(relay_url, temp_keys).await?;
|
|
client.send_event(event).await?;
|
|
client.disconnect().await;
|
|
Ok(())
|
|
}
|
|
|
|
/// Push git repository data to a relay to release a purgatory-held announcement.
|
|
///
|
|
/// Creates a local git repo, sends a state event, and pushes to the relay.
|
|
/// Use this when you need to build a custom announcement but still need the
|
|
/// relay to accept it (i.e., release it from purgatory).
|
|
///
|
|
/// # Arguments
|
|
/// * `relay` - The relay to push to
|
|
/// * `keys` - Keys of the repository owner
|
|
/// * `identifier` - Repository identifier
|
|
/// * `domains` - All domains in the announcement (for state event URLs)
|
|
///
|
|
/// # Returns
|
|
/// `tempfile::TempDir` - Keep alive for test duration
|
|
pub async fn push_git_data_to_relay(
|
|
relay: &TestRelay,
|
|
keys: &Keys,
|
|
identifier: &str,
|
|
domains: &[&str],
|
|
) -> tempfile::TempDir {
|
|
use super::purgatory_helpers::{
|
|
create_state_event, create_test_repo_with_commit, push_to_relay, CommitVariant,
|
|
};
|
|
|
|
let npub = keys
|
|
.public_key()
|
|
.to_bech32()
|
|
.expect("Failed to convert public key to npub");
|
|
|
|
// Create local git repo
|
|
let git_temp_dir = tempfile::tempdir().expect("Failed to create temp dir for git repo");
|
|
let commit_hash = create_test_repo_with_commit(git_temp_dir.path(), CommitVariant::StateTest)
|
|
.expect("Failed to create test git repo");
|
|
|
|
let clone_urls: Vec<String> = domains
|
|
.iter()
|
|
.map(|d| format!("http://{}/{}/{}.git", d, npub, identifier))
|
|
.collect();
|
|
let relay_urls: Vec<String> = domains.iter().map(|d| format!("ws://{}", d)).collect();
|
|
|
|
// Build and send state event with all domains' clone URLs
|
|
let state_event = create_state_event(
|
|
keys,
|
|
identifier,
|
|
&[("main", &commit_hash)],
|
|
&[],
|
|
&clone_urls.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
|
|
&relay_urls.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
|
|
)
|
|
.expect("Failed to create state event");
|
|
|
|
send_to_relay(relay, &state_event)
|
|
.await
|
|
.expect("Failed to send state event");
|
|
|
|
// Git push to relay → releases state event from purgatory, authorizes push
|
|
push_to_relay(git_temp_dir.path(), &relay.domain(), &npub, identifier)
|
|
.expect("Failed to push git data to relay");
|
|
|
|
assert!(
|
|
wait_for_event_on_relay(
|
|
relay.url(),
|
|
Filter::new().id(state_event.id),
|
|
Duration::from_secs(10)
|
|
)
|
|
.await,
|
|
"pushed state event must leave purgatory"
|
|
);
|
|
|
|
git_temp_dir
|
|
}
|
|
|
|
/// Like `push_git_data_to_relay` but writes a unique marker file so each call
|
|
/// produces a distinct commit hash.
|
|
///
|
|
/// Use this when multiple callers push to the same relay with the same identifier
|
|
/// but different keys — identical commit hashes cause git to skip pack transfer,
|
|
/// which can leave the announcement in purgatory.
|
|
///
|
|
/// # Arguments
|
|
/// * `relay` - The relay to push to
|
|
/// * `keys` - Keys of the repository owner
|
|
/// * `identifier` - Repository identifier
|
|
/// * `domains` - All domains in the announcement (for state event URLs)
|
|
/// * `unique_seed` - A string written into a `.unique` file to differentiate commits
|
|
///
|
|
/// # Returns
|
|
/// `tempfile::TempDir` - Keep alive for test duration
|
|
pub async fn push_unique_git_data_to_relay(
|
|
relay: &TestRelay,
|
|
keys: &Keys,
|
|
identifier: &str,
|
|
domains: &[&str],
|
|
unique_seed: &str,
|
|
) -> tempfile::TempDir {
|
|
use super::purgatory_helpers::{create_state_event, push_to_relay};
|
|
|
|
let npub = keys
|
|
.public_key()
|
|
.to_bech32()
|
|
.expect("Failed to convert public key to npub");
|
|
|
|
let git_temp_dir = tempfile::tempdir().expect("Failed to create temp dir for git repo");
|
|
let path = git_temp_dir.path();
|
|
|
|
fn git(path: &std::path::Path, args: &[&str]) {
|
|
let status = grasp_audit::git_command()
|
|
.args(args)
|
|
.current_dir(path)
|
|
.env("GIT_AUTHOR_NAME", "Test User")
|
|
.env("GIT_AUTHOR_EMAIL", "test@example.com")
|
|
.env("GIT_COMMITTER_NAME", "Test User")
|
|
.env("GIT_COMMITTER_EMAIL", "test@example.com")
|
|
.env("GIT_AUTHOR_DATE", "2024-01-01T00:00:00+00:00")
|
|
.env("GIT_COMMITTER_DATE", "2024-01-01T00:00:00+00:00")
|
|
.output()
|
|
.unwrap_or_else(|e| panic!("git {:?} failed to spawn: {}", args, e));
|
|
assert!(
|
|
status.status.success(),
|
|
"git {:?} failed: {}",
|
|
args,
|
|
String::from_utf8_lossy(&status.stderr)
|
|
);
|
|
}
|
|
|
|
git(path, &["init", "--initial-branch=main"]);
|
|
git(path, &["config", "user.email", "test@example.com"]);
|
|
git(path, &["config", "user.name", "Test User"]);
|
|
git(path, &["config", "commit.gpgsign", "false"]);
|
|
|
|
// Write a unique file so each maintainer gets a distinct commit hash
|
|
std::fs::write(
|
|
path.join("state_test.txt"),
|
|
"State test content for purgatory sync",
|
|
)
|
|
.expect("write state_test.txt");
|
|
std::fs::write(path.join(".unique"), unique_seed).expect("write .unique");
|
|
git(path, &["add", "."]);
|
|
git(path, &["commit", "-m", "State test commit"]);
|
|
|
|
let commit_hash = {
|
|
let out = grasp_audit::git_command()
|
|
.args(["rev-parse", "HEAD"])
|
|
.current_dir(path)
|
|
.output()
|
|
.expect("git rev-parse");
|
|
assert!(
|
|
out.status.success(),
|
|
"git rev-parse HEAD failed: {}",
|
|
String::from_utf8_lossy(&out.stderr)
|
|
);
|
|
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
|
};
|
|
|
|
let clone_urls: Vec<String> = domains
|
|
.iter()
|
|
.map(|d| format!("http://{}/{}/{}.git", d, npub, identifier))
|
|
.collect();
|
|
let relay_urls: Vec<String> = domains.iter().map(|d| format!("ws://{}", d)).collect();
|
|
|
|
let state_event = create_state_event(
|
|
keys,
|
|
identifier,
|
|
&[("main", &commit_hash)],
|
|
&[],
|
|
&clone_urls.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
|
|
&relay_urls.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
|
|
)
|
|
.expect("Failed to create state event");
|
|
|
|
send_to_relay(relay, &state_event)
|
|
.await
|
|
.expect("Failed to send state event");
|
|
|
|
push_to_relay(path, &relay.domain(), &npub, identifier)
|
|
.expect("Failed to push git data to relay");
|
|
|
|
assert!(
|
|
wait_for_event_on_relay(
|
|
relay.url(),
|
|
Filter::new().id(state_event.id),
|
|
Duration::from_secs(10)
|
|
)
|
|
.await,
|
|
"pushed state event must leave purgatory"
|
|
);
|
|
|
|
git_temp_dir
|
|
}
|
|
|
|
/// One repository's immutable Git data and signed events for all test relays.
|
|
///
|
|
/// Preparing separate copies can mint competing replaceable events for the same
|
|
/// owner/identifier and different Git commits. Prepare once, list every endpoint
|
|
/// (reserve its listener first if necessary), and install this fixture on each
|
|
/// relay. Keep it alive until the test finishes.
|
|
pub struct RepositoryFixture {
|
|
git_dir: tempfile::TempDir,
|
|
announcement: Event,
|
|
state_event: Event,
|
|
keys: Keys,
|
|
identifier: String,
|
|
}
|
|
|
|
impl RepositoryFixture {
|
|
pub fn new(keys: &Keys, domains: &[&str], identifier: &str) -> Self {
|
|
use super::purgatory_helpers::{
|
|
create_state_event, create_test_repo_with_commit, CommitVariant,
|
|
};
|
|
|
|
let npub = keys
|
|
.public_key()
|
|
.to_bech32()
|
|
.expect("Failed to convert public key to npub");
|
|
|
|
// Create local git repo with a commit
|
|
let git_temp_dir = tempfile::tempdir().expect("Failed to create temp dir for git repo");
|
|
let commit_hash =
|
|
create_test_repo_with_commit(git_temp_dir.path(), CommitVariant::StateTest)
|
|
.expect("Failed to create test git repo");
|
|
|
|
// Build clone URLs and relay URLs from domains
|
|
let clone_urls: Vec<String> = domains
|
|
.iter()
|
|
.map(|d| format!("http://{}/{}/{}.git", d, npub, identifier))
|
|
.collect();
|
|
let relay_urls: Vec<String> = domains.iter().map(|d| format!("ws://{}", d)).collect();
|
|
|
|
// Build announcement event (lists ALL domains for relay discovery)
|
|
let announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "Repository state")
|
|
.tags(vec![
|
|
Tag::identifier(identifier),
|
|
Tag::custom("clone", clone_urls.clone()),
|
|
Tag::custom("relays", relay_urls.clone()),
|
|
])
|
|
.finalize(keys)
|
|
.expect("Failed to sign repo announcement");
|
|
|
|
// Build state event with all domains' clone URLs
|
|
let state_event = create_state_event(
|
|
keys,
|
|
identifier,
|
|
&[("main", &commit_hash)],
|
|
&[],
|
|
&clone_urls.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
|
|
&relay_urls.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
|
|
)
|
|
.expect("Failed to create state event");
|
|
|
|
Self {
|
|
git_dir: git_temp_dir,
|
|
announcement,
|
|
state_event,
|
|
keys: keys.clone(),
|
|
identifier: identifier.to_string(),
|
|
}
|
|
}
|
|
|
|
pub fn path(&self) -> &std::path::Path {
|
|
self.git_dir.path()
|
|
}
|
|
|
|
/// Publish the same events and push the same commit to another relay.
|
|
pub async fn install(&self, relay: &TestRelay) {
|
|
use super::purgatory_helpers::push_to_relay;
|
|
|
|
send_to_relay(relay, &self.announcement)
|
|
.await
|
|
.expect("Failed to send announcement");
|
|
send_to_relay(relay, &self.state_event)
|
|
.await
|
|
.expect("Failed to send state event");
|
|
let npub = self.keys.public_key().to_bech32().expect("owner npub");
|
|
push_to_relay(self.path(), &relay.domain(), &npub, &self.identifier)
|
|
.expect("Failed to push git data to relay");
|
|
|
|
for (label, event) in [
|
|
("state", &self.state_event),
|
|
("announcement", &self.announcement),
|
|
] {
|
|
let visible = wait_for_event_on_relay(
|
|
relay.url(),
|
|
Filter::new().id(event.id),
|
|
Duration::from_secs(10),
|
|
)
|
|
.await;
|
|
if !visible {
|
|
report_setup_visibility_failure(relay, &self.keys, &self.identifier, event).await;
|
|
}
|
|
assert!(visible, "pushed {label} must leave purgatory");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Prepare and install a repository on its first relay. For another relay,
|
|
/// call `install` on the returned fixture instead of preparing a new copy.
|
|
pub async fn setup_announcement_on_relay(
|
|
relay: &TestRelay,
|
|
keys: &Keys,
|
|
domains: &[&str],
|
|
identifier: &str,
|
|
) -> (Event, RepositoryFixture) {
|
|
let fixture = RepositoryFixture::new(keys, domains, identifier);
|
|
fixture.install(relay).await;
|
|
(fixture.announcement.clone(), fixture)
|
|
}
|
|
|
|
/// Diagnose a failed exact-ID check without extending its success deadline.
|
|
async fn report_setup_visibility_failure(
|
|
relay: &TestRelay,
|
|
keys: &Keys,
|
|
identifier: &str,
|
|
expected: &Event,
|
|
) {
|
|
eprintln!(
|
|
"setup visibility failure: relay={} kind={} author={} identifier={} expected_id={} created_at={}",
|
|
relay.url(), expected.kind.as_u16(), expected.pubkey, identifier,
|
|
expected.id, expected.created_at
|
|
);
|
|
// A different event at the same replaceable address distinguishes a lost
|
|
// replacement race from an event that was never promoted or served.
|
|
let diagnostic = tokio::time::timeout(Duration::from_secs(5), async {
|
|
let client = TestClient::new(relay.url(), keys.clone()).await?;
|
|
let result = client
|
|
.client
|
|
.fetch_events(
|
|
Filter::new()
|
|
.kind(expected.kind)
|
|
.author(expected.pubkey)
|
|
.identifier(identifier),
|
|
)
|
|
.timeout(Duration::from_secs(2))
|
|
.await;
|
|
client.disconnect().await;
|
|
let events = result.map_err(|error| error.to_string())?;
|
|
for event in events.iter() {
|
|
eprintln!(
|
|
"stored candidate: id={} created_at={}",
|
|
event.id, event.created_at
|
|
);
|
|
}
|
|
eprintln!("stored candidate count: {}", events.len());
|
|
Ok::<(), String>(())
|
|
})
|
|
.await;
|
|
match diagnostic {
|
|
Ok(Ok(())) => {}
|
|
Ok(Err(error)) => eprintln!("setup diagnostic query failed: {error}"),
|
|
Err(_) => eprintln!("setup diagnostic query timed out"),
|
|
}
|
|
match std::fs::read_to_string(relay.log_path()) {
|
|
Ok(log) => {
|
|
let tail = log.lines().rev().take(80).collect::<Vec<_>>();
|
|
eprintln!("setup relay log tail:");
|
|
for line in tail.into_iter().rev() {
|
|
eprintln!("{line}");
|
|
}
|
|
}
|
|
Err(error) => eprintln!("setup relay log unavailable: {error}"),
|
|
}
|
|
}
|
|
|
|
/// Unified sync test helper that automatically determines sync mode.
|
|
///
|
|
/// This function sets up a complete sync test environment by determining whether
|
|
/// to test historic sync (events sent before syncing relay connects) or live sync
|
|
/// (events sent after syncing relay connects) based on which event slice has content.
|
|
///
|
|
/// # Sync Mode Detection
|
|
///
|
|
/// - **Historic sync**: If `historic_events` has content and `live_events` is empty
|
|
/// - **Live sync**: If `live_events` has content and `historic_events` is empty
|
|
/// - **Panics**: If both slices have content or both are empty (invalid usage)
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `historic_events` - Events to send BEFORE syncing relay connects (for historic sync tests)
|
|
/// * `live_events` - Events to send AFTER syncing relay connects (for live sync tests)
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// [`SyncTestResult`] containing test fixtures for assertions
|
|
///
|
|
/// # Example
|
|
///
|
|
/// ```ignore
|
|
/// // Historic sync test
|
|
/// let issue = build_layer2_issue_event(&keys, &repo_coord, "Historic Issue")?;
|
|
/// let result = run_sync_test(&[issue], &[]).await;
|
|
/// // Assert issue synced to result.syncing_relay
|
|
///
|
|
/// // Live sync test
|
|
/// let comment = build_layer3_comment_event(&keys, &issue.id, "Live Comment", Kind::Comment)?;
|
|
/// let result = run_sync_test(&[], &[comment]).await;
|
|
/// // Assert comment synced to result.syncing_relay
|
|
/// ```
|
|
pub async fn run_sync_test(historic_events: &[Event], live_events: &[Event]) -> SyncTestResult {
|
|
use super::purgatory_helpers::{
|
|
create_state_event, create_test_repo_with_commit, push_to_relay, CommitVariant,
|
|
};
|
|
|
|
// Validate usage - cannot provide events in both slices
|
|
let historic_mode = !historic_events.is_empty();
|
|
let live_mode = !live_events.is_empty();
|
|
|
|
if historic_mode && live_mode {
|
|
panic!(
|
|
"Invalid usage: both historic_events and live_events provided. Use one or the other."
|
|
);
|
|
}
|
|
// Note: Both slices can be empty - this tests just the announcement sync
|
|
|
|
// 1. Pre-allocate syncing relay port for announcement tags. We hold
|
|
// the reservation through the announcement+state-event setup so
|
|
// no other parallel test in this process can be handed the same
|
|
// port number before step 10 starts the relay.
|
|
let syncing_reservation = port::reserve_port();
|
|
let syncing_port = syncing_reservation.port();
|
|
let syncing_domain = format!("127.0.0.1:{}", syncing_port);
|
|
|
|
// 2. Start source relay
|
|
let source = TestRelay::start().await;
|
|
|
|
// 3. Create local git repo with a commit
|
|
let git_temp_dir = tempfile::tempdir().expect("Failed to create temp dir for git repo");
|
|
let commit_hash = create_test_repo_with_commit(git_temp_dir.path(), CommitVariant::StateTest)
|
|
.expect("Failed to create test git repo");
|
|
|
|
// 4. Create keys and build URLs
|
|
let keys = Keys::generate();
|
|
let npub = keys
|
|
.public_key()
|
|
.to_bech32()
|
|
.expect("Failed to convert public key to npub");
|
|
|
|
// Clone URLs: source relay HTTP endpoint is where git data lives
|
|
// The syncing relay's purgatory will fetch from source's clone URL
|
|
let clone_url_source = format!("http://{}/{}/{}.git", source.domain(), npub, "test-repo");
|
|
let clone_url_syncing = format!("http://{}/{}/{}.git", syncing_domain, npub, "test-repo");
|
|
|
|
let clone_urls = vec![clone_url_source.clone(), clone_url_syncing.clone()];
|
|
let relay_urls = vec![
|
|
format!("ws://{}", source.domain()),
|
|
format!("ws://{}", syncing_domain),
|
|
];
|
|
|
|
let announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "Repository state")
|
|
.tags(vec![
|
|
Tag::identifier("test-repo"),
|
|
Tag::custom("clone", clone_urls.clone()),
|
|
Tag::custom("relays", relay_urls.clone()),
|
|
])
|
|
.finalize(&keys)
|
|
.expect("Failed to sign repo announcement");
|
|
|
|
// 5. Create state event referencing the commit
|
|
let state_event = create_state_event(
|
|
&keys,
|
|
"test-repo",
|
|
&[("main", &commit_hash)],
|
|
&[],
|
|
&clone_urls.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
|
|
&relay_urls.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
|
|
)
|
|
.expect("Failed to create state event");
|
|
|
|
// 6. Send announcement + state event to source (both go to purgatory)
|
|
send_to_relay(&source, &announcement)
|
|
.await
|
|
.expect("Failed to send announcement");
|
|
send_to_relay(&source, &state_event)
|
|
.await
|
|
.expect("Failed to send state event");
|
|
|
|
// 7. Git push to source relay → releases both announcement and state event from purgatory
|
|
push_to_relay(git_temp_dir.path(), &source.domain(), &npub, "test-repo")
|
|
.expect("Failed to push git data to source relay");
|
|
|
|
// 8. Wait for source relay to process the push and release events from purgatory
|
|
assert!(
|
|
wait_for_event_on_relay(
|
|
source.url(),
|
|
Filter::new().id(announcement.id),
|
|
Duration::from_secs(10)
|
|
)
|
|
.await,
|
|
"source announcement must leave purgatory"
|
|
);
|
|
|
|
// 9. Send historic events to source BEFORE syncing relay starts
|
|
for event in historic_events {
|
|
send_to_relay(&source, event)
|
|
.await
|
|
.expect("Failed to send historic event");
|
|
}
|
|
|
|
// 10. Start syncing relay (connects to source)
|
|
let syncing = TestRelay::start_on_reservation_with_options(
|
|
syncing_reservation,
|
|
Some(source.url().into()),
|
|
false,
|
|
)
|
|
.await;
|
|
|
|
// 11. Wait for sync connection to establish
|
|
wait_for_sync_connection(syncing.url(), 1, Duration::from_secs(10))
|
|
.await
|
|
.expect("sync connection must establish before publishing live events");
|
|
|
|
// 12. Send live events AFTER connection established
|
|
for event in live_events {
|
|
send_to_relay(&source, event)
|
|
.await
|
|
.expect("Failed to send live event");
|
|
}
|
|
|
|
// 13. Observe announcement promotion rather than guessing a sync duration.
|
|
assert!(
|
|
wait_for_event_on_relay(
|
|
syncing.url(),
|
|
Filter::new().id(announcement.id),
|
|
Duration::from_secs(15)
|
|
)
|
|
.await,
|
|
"synced announcement must leave purgatory"
|
|
);
|
|
|
|
// 14. Compute repo coordinate before moving keys
|
|
let coordinate = repo_coord(&keys, "test-repo");
|
|
|
|
SyncTestResult {
|
|
source_relay: source,
|
|
syncing_relay: syncing,
|
|
maintainer_keys: keys,
|
|
repo_coord: coordinate,
|
|
_git_server: None,
|
|
_git_temp_dir: Some(git_temp_dir),
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Tests for Unified Sync Test Helper
|
|
// ============================================================================
|
|
|
|
#[cfg(test)]
|
|
mod sync_helper_tests {
|
|
use super::*;
|
|
|
|
// Note: Full integration tests of run_sync_test are in the actual sync test modules.
|
|
// These unit tests only verify the panic conditions for invalid usage.
|
|
|
|
#[tokio::test]
|
|
#[should_panic(expected = "both historic_events and live_events provided")]
|
|
async fn test_run_sync_test_panics_with_both_slices() {
|
|
let keys = Keys::generate();
|
|
let coord = repo_coord(&keys, "test");
|
|
let historic =
|
|
build_layer2_issue_event(&keys, &coord, "Historic").expect("Should create event");
|
|
let live = build_layer3_reply_with_e_tag(&keys, &EventId::from_byte_array([0; 32]), "Live")
|
|
.expect("Should create event");
|
|
|
|
// Should panic - both slices provided
|
|
let _result = run_sync_test(&[historic], &[live]).await;
|
|
}
|
|
|
|
// Note: Empty slices are now allowed - tests just the announcement sync
|
|
}
|