clementine_tx_sender/
test_utils.rs

1//! Test utilities for clementine-tx-sender.
2
3use std::net::TcpListener;
4
5use bitcoin::secp256k1::SecretKey;
6use clementine_config::TxSenderLimits;
7use clementine_extended_rpc::ExtendedBitcoinRpc;
8use clementine_utils::tracing::initialize_logger;
9use secrecy::ExposeSecret;
10
11use crate::config::{TxSenderBitcoinRpcConfig, TxSenderConfig, TxSenderPostgresConfig};
12use crate::{MempoolConfig, TxSenderDb};
13
14/// Creates a test environment with a unique database name and a regtest Bitcoin node specific to the test.
15pub async fn create_test_environment(
16    setup_db: bool,
17    setup_rpc: bool,
18) -> (
19    TxSenderConfig,
20    Option<TxSenderDb>,
21    Option<WithProcessCleanup>,
22) {
23    initialize_logger(Some(::tracing::level_filters::LevelFilter::DEBUG))
24        .expect("Failed to initialize logger");
25
26    let mut config = TxSenderConfig {
27        network: bitcoin::Network::Regtest,
28        secret_key: SecretKey::new(&mut bitcoin::secp256k1::rand::thread_rng()),
29        private_da_key: Some(SecretKey::new(&mut bitcoin::secp256k1::rand::thread_rng())),
30        postgres: TxSenderPostgresConfig {
31            host: "127.0.0.1".to_string(),
32            port: 5432,
33            user: "clementine".to_string().into(),
34            password: "clementine".to_string().into(),
35            dbname: format!("clementine_tx_sender_{}", get_current_test_name()),
36        },
37        bitcoin_rpc: TxSenderBitcoinRpcConfig {
38            url: "http://127.0.0.1:18443".to_string(),
39            user: "admin".to_string().into(),
40            password: "admin".to_string().into(),
41        },
42        finality_depth: 1,
43        poll_delay_ms: 500,
44        include_unsafe: true,
45        jsonrpc: None,
46        mempool: MempoolConfig {
47            host: None,
48            endpoint: None,
49        },
50        limits: TxSenderLimits::default(),
51    };
52
53    tracing::info!("Test txsender db name: {}", config.postgres.dbname);
54
55    let rpc = if setup_rpc {
56        Some(create_regtest_rpc(&mut config).await)
57    } else {
58        None
59    };
60    let db = if setup_db {
61        Some(setup_txsender_test_db(&config).await)
62    } else {
63        None
64    };
65
66    (config, db, rpc)
67}
68
69/// Sets up a test database with a unique name based on the current test thread name.
70///
71/// This function follows the same pattern as `core::test::common::create_test_config_with_thread_name`:
72/// it extracts the thread name, creates a unique database name, drops/creates the database,
73/// and runs migrations.
74///
75/// # Panics
76///
77/// Panics if:
78/// - The thread name cannot be retrieved
79/// - Database connection fails
80/// - Database operations fail
81pub async fn setup_txsender_test_db(config: &TxSenderConfig) -> TxSenderDb {
82    let db_name = config.postgres.dbname.clone();
83
84    // Use same defaults as core test util
85    let admin_config = TxSenderPostgresConfig {
86        dbname: "postgres".to_string(),
87        ..config.postgres.clone()
88    };
89
90    // Connect to postgres database to create/drop the test database
91    let admin_db = TxSenderDb::connect(&admin_config)
92        .await
93        .expect("Failed to connect to postgres database");
94
95    // Drop and create the test database
96    let _ = sqlx::query(&format!("DROP DATABASE IF EXISTS {db_name}"))
97        .execute(admin_db.pool())
98        .await;
99
100    let _ = sqlx::query(&format!(
101        "CREATE DATABASE {} WITH OWNER {}",
102        db_name,
103        config.postgres.user.expose_secret()
104    ))
105    .execute(admin_db.pool())
106    .await;
107
108    admin_db.pool().close().await;
109
110    // Connect to the test database
111    let db = TxSenderDb::connect(&config.postgres)
112        .await
113        .expect("Failed to connect to test database");
114    db.run_migrations().await.expect("Failed to run migrations");
115    db
116}
117
118pub struct WithProcessCleanup(
119    /// Handle to the bitcoind process
120    pub Option<std::process::Child>,
121    /// RPC client
122    pub ExtendedBitcoinRpc,
123    /// Path to the bitcoind debug log file
124    pub std::path::PathBuf,
125    /// Whether to wait indefinitely after test finishes before cleanup (for RPC debugging)
126    pub bool,
127);
128impl WithProcessCleanup {
129    pub fn rpc(&self) -> &ExtendedBitcoinRpc {
130        &self.1
131    }
132}
133
134/// Creates a Bitcoin regtest node for testing, waits for it to start and returns an RPC client.
135///
136/// # Environment Variables
137/// - `BITCOIN_RPC_DEBUG`: If set to a non-empty value, will use port 18443 and connect to an existing
138///   bitcoind instance when available.
139///
140/// # Returns
141/// Returns a `WithProcessCleanup` which contains:
142/// - The bitcoind process handle (if a new instance was started)
143/// - An RPC client connected to the node
144/// - Path to the debug log file
145/// - A flag indicating whether to pause before cleanup
146///
147/// # Important
148/// The returned value MUST NOT be dropped until the test is complete, as dropping it will terminate
149/// the bitcoind process and invalidate the RPC connection. The cleanup is handled automatically when
150/// the returned value is dropped.
151pub async fn create_regtest_rpc(config: &mut TxSenderConfig) -> WithProcessCleanup {
152    use bitcoincore_rpc::RpcApi;
153    use tempfile::TempDir;
154
155    // Create temporary directory for bitcoin data
156    let data_dir = TempDir::new()
157        .expect("Failed to create temporary directory")
158        .keep();
159    let bitcoin_rpc_debug = std::env::var("BITCOIN_RPC_DEBUG").map(|d| !d.is_empty()) == Ok(true);
160
161    // Get available ports for RPC
162    let rpc_port = if bitcoin_rpc_debug {
163        18443
164    } else {
165        get_available_port()
166    };
167
168    config.bitcoin_rpc.url = format!("http://127.0.0.1:{rpc_port}");
169
170    if bitcoin_rpc_debug && TcpListener::bind(format!("127.0.0.1:{rpc_port}")).is_err() {
171        // Bitcoind is already running on port 18443, use existing port.
172        return WithProcessCleanup(
173            None,
174            ExtendedBitcoinRpc::connect(
175                "http://127.0.0.1:18443".into(),
176                config.bitcoin_rpc.user.clone(),
177                config.bitcoin_rpc.password.clone(),
178                None,
179            )
180            .await
181            .unwrap(),
182            data_dir.join("debug.log"),
183            false, // no need to wait after test
184        );
185    }
186    // Bitcoin node configuration
187    // Construct args for bitcoind
188    let args = vec![
189        "-regtest".to_string(),
190        format!("-datadir={}", data_dir.display()),
191        "-listen=0".to_string(),
192        format!("-rpcport={}", rpc_port),
193        format!("-rpcuser={}", config.bitcoin_rpc.user.expose_secret()),
194        format!(
195            "-rpcpassword={}",
196            config.bitcoin_rpc.password.expose_secret()
197        ),
198        "-wallet=admin".to_string(),
199        "-txindex=1".to_string(),
200        "-fallbackfee=0.00001".to_string(),
201        "-rpcallowip=0.0.0.0/0".to_string(),
202        "-maxtxfee=5".to_string(),
203    ];
204
205    // Create log file in temp directory
206    let log_file = data_dir.join("debug.log");
207    let log_file_path = log_file
208        .to_str()
209        .expect("Failed to convert log file path to string");
210
211    // Start bitcoind process with log redirection
212    let process = std::process::Command::new("bitcoind")
213        .args(&args)
214        .arg(format!("-debuglogfile={log_file_path}"))
215        .stdout(std::process::Stdio::null())
216        .stderr(std::process::Stdio::null())
217        .spawn()
218        .expect("Failed to start bitcoind");
219
220    if bitcoin_rpc_debug {
221        tracing::warn!("Bitcoind logs are available at {}", log_file_path);
222    }
223
224    // Create RPC client
225    let rpc_url = format!("http://127.0.0.1:{rpc_port}");
226
227    // Wait for node to be ready
228    let mut attempts = 0;
229    let retry_count = 30;
230    let client = loop {
231        match ExtendedBitcoinRpc::connect(
232            rpc_url.clone(),
233            config.bitcoin_rpc.user.clone(),
234            config.bitcoin_rpc.password.clone(),
235            None,
236        )
237        .await
238        {
239            Ok(client) => break client,
240            Err(_) => {
241                attempts += 1;
242                if attempts >= retry_count {
243                    panic!("Bitcoin node failed to start in {retry_count} seconds");
244                }
245                tokio::time::sleep(std::time::Duration::from_secs(1)).await;
246            }
247        }
248    };
249
250    // Get and print bitcoind version
251    let network_info = client
252        .get_network_info()
253        .await
254        .expect("Failed to get network info");
255    tracing::info!("Using bitcoind version: {}", network_info.version);
256
257    // // Create wallet
258    client
259        .create_wallet("admin", None, None, None, None)
260        .await
261        .expect("Failed to create wallet");
262
263    // Generate blocks
264    let address = client
265        .get_new_address(None, None)
266        .await
267        .expect("Failed to get new address");
268
269    // generate funds to wallet
270    client
271        .generate_to_address(201, address.assume_checked_ref())
272        .await
273        .expect("Failed to generate blocks");
274
275    WithProcessCleanup(Some(process), client.clone(), log_file, bitcoin_rpc_debug)
276}
277
278/// Helper to get a dynamically assigned free port.
279pub fn get_available_port() -> u16 {
280    use std::net::TcpListener;
281    TcpListener::bind("127.0.0.1:0")
282        .expect("Could not bind to an available port")
283        .local_addr()
284        .expect("Could not get local address")
285        .port()
286}
287
288pub fn get_current_test_name() -> String {
289    // 1. Try the standard thread name (works for standard `cargo test`)
290    let test_name = std::thread::current()
291        .name()
292        .unwrap_or("main")
293        .split(':')
294        .next_back()
295        .unwrap_or("main")
296        .to_string();
297
298    // 2. If running via `cargo nextest`, the thread name is often "main".
299    //    In this case, we parse the test name from the CLI arguments.
300    if test_name == "main" {
301        // Use the Process ID. It is unique for every parallel test.
302        let pid = std::process::id();
303        format!("{pid}")
304    } else {
305        test_name
306    }
307}