Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions monero-rpc-pool/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,6 @@ swap-serde = { path = "../swap-serde" }
# Optional dependencies (for features)
cuprate-epee-encoding = { git = "https://github.com/Cuprate/cuprate.git", optional = true }
reqwest = { version = "0.11", features = ["json"], optional = true }

[dev-dependencies]
tempfile = "3"
92 changes: 92 additions & 0 deletions monero-rpc-pool/src/database.rs
Original file line number Diff line number Diff line change
Expand Up @@ -294,3 +294,95 @@ impl Database {
Ok(addresses)
}
}

#[cfg(test)]
mod tests {
use super::*;

async fn temp_db() -> (tempfile::TempDir, Database) {
let temp_dir = tempfile::tempdir().expect("temporary database directory");
let db = Database::new(temp_dir.path().to_path_buf())
.await
.expect("database initialization");
(temp_dir, db)
}

#[test]
fn network_round_trips_between_enum_and_db_string() {
assert!(matches!(
parse_network("mainnet").unwrap(),
Network::Mainnet
));
assert!(matches!(
parse_network("STAGENET").unwrap(),
Network::Stagenet
));
assert!(matches!(
parse_network("testnet").unwrap(),
Network::Testnet
));
assert!(parse_network("regtest").is_err());

assert_eq!(network_to_string(&Network::Mainnet), "mainnet");
assert_eq!(network_to_string(&Network::Stagenet), "stagenet");
assert_eq!(network_to_string(&Network::Testnet), "testnet");
}

#[tokio::test]
async fn migrations_seed_default_nodes_for_each_network() {
let (_temp_dir, db) = temp_db().await;

let (mainnet_total, mainnet_reachable, mainnet_reliable) =
db.get_node_stats("mainnet").await.unwrap();
let (stagenet_total, stagenet_reachable, stagenet_reliable) =
db.get_node_stats("stagenet").await.unwrap();

assert!(mainnet_total > 0);
assert!(stagenet_total > 0);
assert_eq!(mainnet_reachable, 0);
assert_eq!(mainnet_reliable, 0);
assert_eq!(stagenet_reachable, 0);
assert_eq!(stagenet_reliable, 0);
}

#[tokio::test]
async fn health_checks_update_stats_and_reliable_nodes() {
let (_temp_dir, db) = temp_db().await;

db.record_health_check("http", "stagenet.xmr-tw.org", 38081, true, Some(250.0))
.await
.unwrap();
db.record_health_check("http", "stagenet.xmr-tw.org", 38081, false, None)
.await
.unwrap();

let (_total, reachable, reliable) = db.get_node_stats("stagenet").await.unwrap();
assert_eq!(reachable, 1);
assert_eq!(reliable, 0);

let (successful, unsuccessful) = db.get_health_check_stats("stagenet").await.unwrap();
assert_eq!((successful, unsuccessful), (1, 1));

let reliable_nodes = db.get_reliable_nodes("stagenet").await.unwrap();
let node = reliable_nodes
.iter()
.find(|node| node.address.host == "stagenet.xmr-tw.org")
.expect("health-checked node should be considered");
assert_eq!(node.health.success_count, 1);
assert_eq!(node.health.failure_count, 1);
assert_eq!(node.health.avg_latency_ms, Some(250.0));
assert_eq!(node.success_rate(), 0.5);
}

#[tokio::test]
async fn unknown_node_health_checks_are_ignored() {
let (_temp_dir, db) = temp_db().await;

db.record_health_check("http", "missing.example", 18081, true, Some(10.0))
.await
.unwrap();

let (successful, unsuccessful) = db.get_health_check_stats("mainnet").await.unwrap();
assert_eq!((successful, unsuccessful), (0, 0));
}
}
29 changes: 29 additions & 0 deletions monero-rpc-pool/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,3 +185,32 @@ pub async fn start_server_with_random_port(

Ok((server_info, status_receiver, pool_handle))
}

#[cfg(test)]
mod tests {
use super::*;
use monero_address::Network;
use tokio::time::{Duration, timeout};

#[tokio::test]
async fn create_app_publishes_initial_status() {
let temp_dir = tempfile::tempdir().expect("temporary database directory");
let config = Config::new_with_port(
"127.0.0.1".to_string(),
0,
temp_dir.path().to_path_buf(),
Network::Stagenet,
);

let (_app, mut receiver, _handle) = create_app_with_receiver(config).await.unwrap();
let status = timeout(Duration::from_secs(1), receiver.recv())
.await
.expect("initial status timeout")
.expect("initial status");

assert!(status.total_node_count > 0);
assert_eq!(status.healthy_node_count, 0);
assert_eq!(status.successful_health_checks, 0);
assert_eq!(status.unsuccessful_health_checks, 0);
}
}
72 changes: 72 additions & 0 deletions monero-rpc-pool/src/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -248,3 +248,75 @@ impl NodePool {
Ok(selected_nodes)
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::database::Database;
use monero_address::Network;
use tokio::time::{Duration, timeout};

async fn temp_pool() -> (tempfile::TempDir, NodePool, broadcast::Receiver<PoolStatus>) {
let temp_dir = tempfile::tempdir().expect("temporary database directory");
let db = Database::new(temp_dir.path().to_path_buf())
.await
.expect("database initialization");
let (pool, receiver) = NodePool::new(db, Network::Stagenet);
(temp_dir, pool, receiver)
}

#[test]
fn bandwidth_tracker_requires_enough_samples() {
let tracker = BandwidthTracker::new();
for _ in 0..4 {
tracker.record_bytes(1024);
}

assert_eq!(tracker.get_kb_per_sec(), 0.0);
}

#[tokio::test]
async fn status_reports_health_counts_and_reliable_nodes() {
let (_temp_dir, pool, _receiver) = temp_pool().await;

pool.record_success("http", "stagenet.xmr-tw.org", 38081, 120.0)
.await
.unwrap();
pool.record_failure("http", "node.monerodevs.org", 38089)
.await
.unwrap();

let status = pool.get_current_status().await.unwrap();

assert!(status.total_node_count > 0);
assert_eq!(status.healthy_node_count, 1);
assert_eq!(status.successful_health_checks, 1);
assert_eq!(status.unsuccessful_health_checks, 1);
assert!(
status
.top_reliable_nodes
.iter()
.any(|node| node.url == "http://stagenet.xmr-tw.org:38081"
&& node.success_rate == 1.0
&& node.avg_latency_ms == Some(120.0))
);
}

#[tokio::test]
async fn publish_status_update_notifies_receivers() {
let (_temp_dir, pool, mut receiver) = temp_pool().await;

pool.record_success("http", "stagenet.xmr-tw.org", 38081, 100.0)
.await
.unwrap();
pool.publish_status_update().await.unwrap();

let status = timeout(Duration::from_secs(1), receiver.recv())
.await
.expect("status broadcast timeout")
.expect("status broadcast");

assert_eq!(status.healthy_node_count, 1);
assert_eq!(status.successful_health_checks, 1);
}
}
91 changes: 91 additions & 0 deletions monero-rpc-pool/src/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -830,3 +830,94 @@ pub async fn stats_handler(State(state): State<AppState>) -> Response {
.instrument(info_span!("stats_request"))
.await
}

#[cfg(test)]
mod tests {
use super::*;
use axum::http::Request;

async fn body_bytes(response: Response) -> Vec<u8> {
response
.into_body()
.collect()
.await
.expect("response body")
.to_bytes()
.to_vec()
}

#[tokio::test]
async fn cloneable_request_buffers_body_and_extracts_jsonrpc_method() {
let request = Request::builder()
.uri("/json_rpc")
.body(Body::from(r#"{"jsonrpc":"2.0","method":"get_info"}"#))
.unwrap();

let cloneable = CloneableRequest::from_request(request).await.unwrap();

assert_eq!(cloneable.uri(), "/json_rpc");
assert_eq!(cloneable.jsonrpc_method(), Some("get_info".to_string()));

let cloned_request = cloneable.to_request();
let cloned_body = cloned_request
.into_body()
.collect()
.await
.unwrap()
.to_bytes();
assert_eq!(cloned_body.as_ref(), cloneable.body.as_slice());
}

#[tokio::test]
async fn cloneable_request_marks_safe_block_downloads_as_clearnet() {
let blocks = CloneableRequest::from_request(
Request::builder()
.uri("/getblocks.bin")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let rpc = CloneableRequest::from_request(
Request::builder()
.uri("/json_rpc")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();

assert!(blocks.clearnet_whitelisted());
assert!(!rpc.clearnet_whitelisted());
}

#[tokio::test]
async fn cloneable_response_detects_string_jsonrpc_errors() {
let response = Response::builder()
.status(StatusCode::OK)
.body(Body::from(r#"{"error":"daemon busy"}"#))
.unwrap();

let cloneable = CloneableResponse::from_response(response).await.unwrap();

assert_eq!(cloneable.status(), StatusCode::OK);
assert_eq!(
cloneable.get_jsonrpc_error(),
Some("daemon busy".to_string())
);
assert_eq!(get_jsonrpc_error(b"not json"), None);
}

#[tokio::test]
async fn handler_errors_render_status_and_details_json() {
let response = HandlerError::NoNodes.to_response();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);

let body = body_bytes(response).await;
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();

assert_eq!(json["error"]["code"], 503);
assert_eq!(json["error"]["message"], "No nodes available");
assert_eq!(json["error"]["details"], "No nodes available");
}
}
43 changes: 43 additions & 0 deletions monero-rpc-pool/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,46 @@ impl NodeRecord {
self.health.success_rate()
}
}

#[cfg(test)]
mod tests {
use super::*;
use monero_address::Network;

#[test]
fn node_address_formats_full_url_and_display() {
let address = NodeAddress::new("https".to_string(), "node.example".to_string(), 18089);

assert_eq!(address.full_url(), "https://node.example:18089");
assert_eq!(address.to_string(), "https://node.example:18089");
}

#[test]
fn node_health_success_rate_handles_empty_and_mixed_counts() {
let empty = NodeHealthStats::default();
assert_eq!(empty.success_rate(), 0.0);

let mixed = NodeHealthStats {
success_count: 3,
failure_count: 1,
..Default::default()
};
assert_eq!(mixed.success_rate(), 0.75);
}

#[test]
fn node_record_delegates_url_and_success_rate() {
let record = NodeRecord::new(
NodeAddress::new("http".to_string(), "localhost".to_string(), 38081),
NodeMetadata::new(7, Network::Stagenet, Utc::now()),
NodeHealthStats {
success_count: 9,
failure_count: 1,
..Default::default()
},
);

assert_eq!(record.full_url(), "http://localhost:38081");
assert_eq!(record.success_rate(), 0.9);
}
}