diff --git a/.github/workflows/cont_integration.yml b/.github/workflows/cont_integration.yml index 9b6d56c..2e321b8 100644 --- a/.github/workflows/cont_integration.yml +++ b/.github/workflows/cont_integration.yml @@ -79,6 +79,9 @@ jobs: - name: Run doc tests run: cargo test ${{ matrix.features }} --doc --verbose + - name: Run RPC client tests + run: cargo test --test test_rpc_client --verbose -- --test-threads=2 + # MSRV msrv: name: MSRV diff --git a/Cargo.lock b/Cargo.lock index 4d060cc..f25a144 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,6 +52,7 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" name = "bdk-bitcoind-client" version = "0.1.0" dependencies = [ + "anyhow", "corepc-node", "corepc-types 0.11.0", "jsonrpc 0.19.0", diff --git a/Cargo.toml b/Cargo.toml index f792ef4..e06032e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,4 +22,5 @@ default = ["30_0"] 28_0 = [] [dev-dependencies] +anyhow = "1" corepc-node = { version = "0.10.1", features = ["download", "29_0"] } diff --git a/src/client.rs b/src/client.rs index b9f58dc..3f7201b 100644 --- a/src/client.rs +++ b/src/client.rs @@ -284,6 +284,7 @@ mod test_auth { } #[test] + #[ignore = "modifies the local filesystem"] fn test_auth_cookie_file_get_user_pass() { let temp_dir = std::env::temp_dir(); let cookie_path = temp_dir.join("test_auth_cookie"); @@ -299,4 +300,14 @@ mod test_auth { std::fs::remove_file(cookie_path).ok(); } + + #[test] + fn test_auth_invalid_cookie_file() { + let dummy_url = "http://127.0.0.1:18443"; + let cookie_path = PathBuf::from("/nonexistent/path/to/cookie"); + + let result = Client::with_auth(dummy_url, Auth::CookieFile(cookie_path)); + + assert!(matches!(result, Err(Error::InvalidCookieFile))); + } } diff --git a/src/error.rs b/src/error.rs index 5574ca4..3e8f3ba 100644 --- a/src/error.rs +++ b/src/error.rs @@ -67,22 +67,7 @@ impl fmt::Display for Error { } } -impl std::error::Error for Error { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Error::DecodeHex(e) => Some(e), - Error::JsonRpc(e) => Some(e), - Error::HexToArray(e) => Some(e), - Error::Json(e) => Some(e), - Error::Io(e) => Some(e), - Error::TryFromInt(e) => Some(e), - Error::GetBlockVerboseOne(e) => Some(e), - Error::GetBlockHeaderVerbose(e) => Some(e), - Error::GetBlockFilter(e) => Some(e), - _ => None, - } - } -} +impl std::error::Error for Error {} // Conversions from other error types impl From for Error { diff --git a/tests/test_rpc_client.rs b/tests/test_rpc_client.rs index 159bfcf..676b408 100644 --- a/tests/test_rpc_client.rs +++ b/tests/test_rpc_client.rs @@ -1,83 +1,20 @@ -//! Integration tests for the Bitcoin RPC client. +//! Integration tests for the `bdk_bitcoind_client` [`Client`]. //! -//! These tests require a running Bitcoin Core node in regtest mode. -//! -//! Setup: -//! ```bash -//! bitcoind -regtest -rpcuser=bitcoin -rpcpassword=bitcoin -rpcport=18443 -//! ``` +//! These tests require a running Bitcoin Core node in regtest mode. To setup refer to [`corepc_node`]. use bdk_bitcoind_client::{Auth, Client, Error}; -use corepc_node::{exe_path, Conf, Node}; -use corepc_types::bitcoin::{BlockHash, Txid}; -use jsonrpc::serde_json::json; -use std::{path::PathBuf, str::FromStr}; - -/// Helper to initialize the bitcoind executable path -fn init() -> String { - exe_path().expect("bitcoind executable not found. Set BITCOIND_EXE or enable download feature.") -} - -/// Helper to set up a clean bitcoind node and return the client. -fn setup() -> (Client, Node) { - let exe = init(); - - let mut conf = Conf::default(); - - conf.args.push("-blockfilterindex=1"); - conf.args.push("-txindex=1"); - - let node = Node::with_conf(exe, &conf).expect("Failed to start node"); - - let rpc_url = node.rpc_url(); - let cookie = node - .params - .get_cookie_values() - .expect("Failed to read cookie") - .expect("Cookie file empty"); - - let auth = Auth::UserPass(cookie.user, cookie.password); - - let client = Client::with_auth(&rpc_url, auth).expect("failed to create client"); - - (client, node) -} - -/// Helper to mine blocks -fn mine_blocks(client: &Client, n: u64) -> Result, Error> { - let address: String = client.call("getnewaddress", &[])?; - client.call("generatetoaddress", &[json!(n), json!(address)]) -} - -#[test] -fn test_client_with_user_pass() { - let (client, mut node) = setup(); +use corepc_types::bitcoin::{Amount, BlockHash, Txid}; +use std::str::FromStr; - let block_hash = client - .get_best_block_hash() - .expect("failed to call getbestblockhash"); +mod testenv; - assert_eq!( - block_hash.to_string().len(), - 64, - "block hash should be 64 characters" - ); - assert!( - block_hash - .to_string() - .chars() - .all(|c| c.is_ascii_hexdigit()), - "hash should only contain hex digits" - ); - - node.stop().expect("failed to stop node"); -} +use testenv::TestEnv; #[test] fn test_invalid_credentials() { - let (_, mut node) = setup(); + let env = TestEnv::setup().unwrap(); let client = Client::with_auth( - &node.rpc_url(), + &env.node.rpc_url(), Auth::UserPass("wrong".to_string(), "credentials".to_string()), ) .expect("client creation should succeed"); @@ -85,38 +22,17 @@ fn test_invalid_credentials() { let result: Result = client.get_best_block_hash(); assert!(result.is_err()); - - node.stop().expect("failed to stop node"); -} - -#[test] -fn test_invalid_cookie_file() { - let dummy_url = "http://127.0.0.1:18443"; - let cookie_path = PathBuf::from("/nonexistent/path/to/cookie"); - - let result = Client::with_auth(dummy_url, Auth::CookieFile(cookie_path)); - - assert!( - result.is_err(), - "Client should fail when cookie file is missing" - ); - - match result { - Err(Error::InvalidCookieFile) => (), - Err(Error::Io(ref e)) if e.kind() == std::io::ErrorKind::NotFound => (), - Err(e) => panic!("Expected InvalidCookieFile or NotFound Io error, got: {e:?}"), - _ => panic!("Expected an error but got Ok"), - } } #[test] fn test_client_with_custom_transport() { use jsonrpc::http::bitreq_http::Builder; - let (_, node) = setup(); + let env = TestEnv::setup().unwrap(); - let rpc_url = node.rpc_url(); - let cookie = node + let rpc_url = env.node.rpc_url(); + let cookie = env + .node .params .get_cookie_values() .expect("Failed to read cookie") @@ -131,105 +47,81 @@ fn test_client_with_custom_transport() { let client = Client::with_transport(transport); - let result = client + let _result = client .get_best_block_hash() .expect("failed to call getbestblockhash"); - - assert_eq!( - result.to_string().len(), - 64, - "block hash should be 64 characters" - ); } #[test] fn test_get_block_count() { - let (client, mut node) = setup(); + let env = TestEnv::setup().unwrap(); - let block_count = client.get_block_count().expect("failed to get block count"); + let block_count = env + .client + .get_block_count() + .expect("failed to get block count"); assert_eq!(block_count, 0); - - node.stop().expect("failed to stop node"); } #[test] fn test_get_block_hash() { - let (client, mut node) = setup(); + let env = TestEnv::setup().unwrap(); - let genesis_hash = client + let _genesis_hash = env + .client .get_block_hash(0) .expect("failed to get genesis block hash"); - - assert_eq!(genesis_hash.to_string().len(), 64); - - node.stop().expect("failed to stop node"); } #[test] fn test_get_block_hash_for_current_height() { - let (client, mut node) = setup(); + let TestEnv { + client, + node: _node, + } = TestEnv::setup().unwrap(); let block_count = client.get_block_count().expect("failed to get block count"); - let block_hash = client + let _block_hash = client .get_block_hash(block_count) .expect("failed to get block hash"); - - assert_eq!(block_hash.to_string().len(), 64); - node.stop().expect("failed to stop node"); } #[test] fn test_get_block_hash_invalid_height() { - let (client, mut node) = setup(); + let env = TestEnv::setup().unwrap(); - let result = client.get_block_hash(999999999); + let result = env.client.get_block_hash(999_999_999); assert!(result.is_err()); - node.stop().expect("failed to stop node"); } #[test] fn test_get_best_block_hash() { - let (client, mut node) = setup(); + let TestEnv { + client, + node: _node, + } = TestEnv::setup().unwrap(); let best_block_hash = client .get_best_block_hash() .expect("failed to get best block hash"); - assert_eq!(best_block_hash.to_string().len(), 64); - let block_count = client.get_block_count().expect("failed to get block count"); let block_hash = client .get_block_hash(block_count) .expect("failed to get block hash"); assert_eq!(best_block_hash, block_hash); - node.stop().expect("failed to stop node"); -} - -#[test] -fn test_get_best_block_hash_changes_after_mining() { - let (client, mut node) = setup(); - - let hash_before = client - .get_best_block_hash() - .expect("failed to get best block hash"); - - mine_blocks(&client, 1).expect("failed to mine block"); - - let hash_after = client - .get_best_block_hash() - .expect("failed to get best block hash"); - - assert_ne!(hash_before, hash_after); - node.stop().expect("failed to stop node"); } #[test] fn test_get_block() { - let (client, mut node) = setup(); + let TestEnv { + client, + node: _node, + } = TestEnv::setup().unwrap(); let genesis_hash = client .get_block_hash(0) @@ -241,40 +133,59 @@ fn test_get_block() { assert_eq!(block.block_hash(), genesis_hash); assert!(!block.txdata.is_empty()); - node.stop().expect("failed to stop node"); } #[test] fn test_get_block_after_mining() { - let (client, mut node) = setup(); + let env = TestEnv::setup().unwrap(); - let hashes = mine_blocks(&client, 1).expect("failed to mine block"); - let block_hash = BlockHash::from_str(&hashes[0]).expect("invalid hash"); + let hashes = env.mine_blocks(1, None).expect("failed to mine block"); + let block_hash = hashes[0]; - let block = client.get_block(&block_hash).expect("failed to get block"); + let block = env + .client + .get_block(&block_hash) + .expect("failed to get block"); assert_eq!(block.block_hash(), block_hash); assert!(!block.txdata.is_empty()); - node.stop().expect("failed to stop node"); +} + +#[test] +fn test_get_block_verbose() { + let env = TestEnv::setup().unwrap(); + + let hashes = env.mine_blocks(1, None).expect("failed to mine block"); + let block_hash = hashes[0]; + + let get_block_verbose_one = env + .client + .get_block_verbose(&block_hash) + .expect("failed to get block verbose 1"); + + assert_eq!(get_block_verbose_one.hash, block_hash); + assert_eq!(get_block_verbose_one.confirmations, 1); } #[test] fn test_get_block_invalid_hash() { - let (client, mut node) = setup(); + let env = TestEnv::setup().unwrap(); let invalid_hash = BlockHash::from_str("0000000000000000000000000000000000000000000000000000000000000000") .unwrap(); - let result = client.get_block(&invalid_hash); + let result = env.client.get_block(&invalid_hash); assert!(result.is_err()); - node.stop().expect("failed to stop node"); } #[test] fn test_get_block_header() { - let (client, mut node) = setup(); + let TestEnv { + client, + node: _node, + } = TestEnv::setup().unwrap(); let genesis_hash = client .get_block_hash(0) @@ -285,114 +196,113 @@ fn test_get_block_header() { .expect("failed to get block header"); assert_eq!(header.block_hash(), genesis_hash); - node.stop().expect("failed to stop node"); } #[test] -fn test_get_block_header_has_valid_fields() { - let (client, mut node) = setup(); +fn test_get_block_header_verbose() { + let TestEnv { + client, + node: _node, + } = TestEnv::setup().unwrap(); let genesis_hash = client .get_block_hash(0) .expect("failed to get genesis hash"); let header = client - .get_block_header(&genesis_hash) - .expect("failed to get block header"); + .get_block_header_verbose(&genesis_hash) + .expect("failed to get block header verbose"); - assert!(header.time > 0); - assert!(header.nonce >= 1); - node.stop().expect("failed to stop node"); + assert_eq!(header.hash, genesis_hash); } #[test] fn test_get_raw_mempool_empty() { - let (client, mut node) = setup(); + let env = TestEnv::setup().unwrap(); - mine_blocks(&client, 1).expect("failed to mine block"); + let _hashes = env.mine_blocks(1, None).expect("failed to mine block"); std::thread::sleep(std::time::Duration::from_millis(100)); - let mempool = client.get_raw_mempool().expect("failed to get mempool"); + let mempool = env.client.get_raw_mempool().expect("failed to get mempool"); assert!(mempool.is_empty()); - node.stop().expect("failed to stop node"); } #[test] fn test_get_raw_mempool_with_transaction() { - let (client, mut node) = setup(); - - mine_blocks(&client, 101).expect("failed to mine blocks"); - - let address: String = client - .call("getnewaddress", &[]) - .expect("failed to get address"); - let txid: String = client - .call("sendtoaddress", &[json!(address), json!(0.001)]) - .expect("failed to send transaction"); - - let mempool = client.get_raw_mempool().expect("failed to get mempool"); - - let txid_parsed = Txid::from_str(&txid).unwrap(); - assert!(mempool.contains(&txid_parsed)); - node.stop().expect("failed to stop node"); + let env = TestEnv::setup().unwrap(); + + let _hashes = env.mine_blocks(101, None).expect("failed to mine block"); + + let address = env.node.client.new_address().unwrap(); + let txid = env + .node + .client + .send_to_address(&address, Amount::from_btc(0.001).unwrap()) + .expect("failed to send to address") + .into_model() + .unwrap() + .txid; + + let mempool = env.client.get_raw_mempool().expect("failed to get mempool"); + assert!(mempool.contains(&txid)); } #[test] fn test_get_raw_transaction() { - let (client, mut node) = setup(); + let env = TestEnv::setup().unwrap(); - mine_blocks(&client, 1).expect("failed to mine block"); + let _hashes = env.mine_blocks(1, None).expect("failed to mine block"); - let best_hash = client + let best_hash = env + .client .get_best_block_hash() .expect("failed to get best block hash"); - let block = client.get_block(&best_hash).expect("failed to get block"); + let block = env + .client + .get_block(&best_hash) + .expect("failed to get block"); - let txid = &block.txdata[0].compute_txid(); + let expected_tx = &block.txdata[0]; + let txid = expected_tx.compute_txid(); - let tx = client - .get_raw_transaction(txid) + let result_tx = env + .client + .get_raw_transaction(&txid) .expect("failed to get raw transaction"); - assert_eq!(tx.compute_txid(), *txid); - assert!(!tx.input.is_empty()); - assert!(!tx.output.is_empty()); - node.stop().expect("failed to stop node"); + assert_eq!(result_tx, *expected_tx); + assert_eq!(result_tx.compute_txid(), txid); } #[test] fn test_get_raw_transaction_invalid_txid() { - let (client, mut node) = setup(); + let env = TestEnv::setup().unwrap(); let fake_txid = Txid::from_str("0000000000000000000000000000000000000000000000000000000000000000").unwrap(); - let result = client.get_raw_transaction(&fake_txid); + let result = env.client.get_raw_transaction(&fake_txid); assert!(result.is_err()); - node.stop().expect("failed to stop node"); } #[test] fn test_get_block_filter() { - let (client, mut node) = setup(); + let TestEnv { + client, + node: _node, + } = TestEnv::setup().unwrap(); let genesis_hash = client .get_block_hash(0) .expect("failed to get genesis hash"); - let result = client.get_block_filter(&genesis_hash); - - match result { - Ok(filter) => { - assert!(!filter.filter.is_empty()); - } - Err(_) => { - println!("Block filters not enabled (requires -blockfilterindex=1)"); - } - } - node.stop().expect("failed to stop node"); + let result = client + .get_block_filter(&genesis_hash) + .expect("failed to get block filter"); + + assert!(!result.filter.is_empty()); } diff --git a/tests/testenv.rs b/tests/testenv.rs new file mode 100644 index 0000000..691976c --- /dev/null +++ b/tests/testenv.rs @@ -0,0 +1,62 @@ +use bdk_bitcoind_client::{Auth, Client}; +use bitcoin::{Address, BlockHash}; +use corepc_node::{exe_path, Conf, Node}; +use corepc_types::bitcoin; + +/// Test environment for running integration tests. +/// +/// [`TestEnv`] exposes the [`Client`] API defined by this crate to be tested against +/// a running [`corepc_node::Node`] instance. +#[derive(Debug)] +pub struct TestEnv { + /// [`bdk_bitcoind_client::Client`] + pub client: Client, + /// [`corepc_node::Node`] + pub node: Node, +} + +impl TestEnv { + /// Create new [`TestEnv`]. + /// + /// This will first look for the path of the `bitcoind` executable using [`corepc_node::exe_path`] + /// before returning a new [`TestEnv`] with [`Client`] connected to it. + /// + /// Note that [`Node`] also exposes its own RPC [`client`](Node::client) which may help with + /// creating different test cases, but be aware that this is different from the client we're + /// actually testing. + pub fn setup() -> anyhow::Result { + let exe = exe_path()?; + + let mut conf = Conf::default(); + conf.args.push("-blockfilterindex=1"); + conf.args.push("-txindex=1"); + + let node = Node::with_conf(exe, &conf)?; + + let rpc_url = node.rpc_url(); + let cookie_file = &node.params.cookie_file; + let auth = Auth::CookieFile(cookie_file.clone()); + let client = Client::with_auth(&rpc_url, auth)?; + + Ok(Self { client, node }) + } + + /// Mines `nblocks` blocks to the given `address`, or an address controlled + /// by the [`Node`] if not provided. + pub fn mine_blocks( + &self, + nblocks: usize, + address: Option
, + ) -> anyhow::Result> { + let address = match address { + Some(addr) => addr, + None => self.node.client.new_address()?, + }; + Ok(self + .node + .client + .generate_to_address(nblocks, &address)? + .into_model()? + .0) + } +}