Skip to content

Commit 91ee5e1

Browse files
committed
test: Add stateful test wallets
Adds a reusable test framework (`TestWallet`) that creates in-memory wallets backed by SQLite for isolated integration tests of RPC methods. Each test creates a fresh `TestWallet` with a unique database name, so `cargo test` can run all tests in parallel without interference. As an example, stateful wallet tests are included in this commit for `z_listaccounts` and for a few keystore operations.
1 parent 9a2184c commit 91ee5e1

File tree

8 files changed

+584
-3
lines changed

8 files changed

+584
-3
lines changed

zallet/src/components.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ pub(crate) mod tracing;
1616
#[cfg(zallet_build = "wallet")]
1717
pub(crate) mod keystore;
1818

19+
#[cfg(test)]
20+
#[cfg(zallet_build = "wallet")]
21+
pub(crate) mod testing;
22+
1923
/// A handle to a background task spawned by a component.
2024
///
2125
/// Background tasks in Zallet are either one-shot (expected to terminate before Zallet),

zallet/src/components/database.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ mod ext;
2525
#[cfg(test)]
2626
mod tests;
2727

28+
#[cfg(test)]
29+
pub(crate) mod testing;
30+
2831
pub(crate) type DbHandle = deadpool::managed::Object<connection::WalletManager>;
2932

3033
/// Returns the full list of migrations defined in Zallet, to be applied alongside the
@@ -52,14 +55,23 @@ impl fmt::Debug for Database {
5255
}
5356

5457
impl Database {
58+
/// Creates a Database from an existing pool.
59+
///
60+
/// Note: This does NOT apply migrations. Caller must ensure
61+
/// the database is already initialized.
62+
#[cfg(test)]
63+
pub(crate) fn from_pool(pool: connection::WalletPool) -> Self {
64+
Self { db_data_pool: pool }
65+
}
66+
5567
pub(crate) async fn open(config: &ZalletConfig) -> Result<Self, Error> {
5668
let path = config.wallet_db_path();
5769

5870
let db_exists = fs::try_exists(&path)
5971
.await
6072
.map_err(|e| ErrorKind::Init.context(e))?;
6173

62-
let db_data_pool = connection::pool(&path, config.consensus.network())?;
74+
let db_data_pool = connection::pool(&path, config.consensus.network(), None)?;
6375

6476
let database = Self { db_data_pool };
6577

zallet/src/components/database/connection.rs

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,38 @@ use crate::{
2828
network::Network,
2929
};
3030

31-
pub(super) fn pool(path: impl AsRef<Path>, params: Network) -> Result<WalletPool, Error> {
31+
/// Configuration for the database connection pool.
32+
#[derive(Clone, Debug)]
33+
pub(crate) struct PoolConfig {
34+
/// Maximum number of connections in the pool.
35+
pub max_size: usize,
36+
}
37+
38+
impl Default for PoolConfig {
39+
fn default() -> Self {
40+
// Default deadpool size is 16
41+
Self { max_size: 16 }
42+
}
43+
}
44+
45+
pub(super) fn pool(
46+
path: impl AsRef<Path>,
47+
params: Network,
48+
pool_config: Option<PoolConfig>,
49+
) -> Result<WalletPool, Error> {
3250
let config = deadpool_sqlite::Config::new(path.as_ref());
3351
let manager = WalletManager::from_config(&config, params);
52+
53+
let dp_config = match pool_config {
54+
Some(cfg) => deadpool::managed::PoolConfig {
55+
max_size: cfg.max_size,
56+
..deadpool::managed::PoolConfig::default()
57+
},
58+
None => deadpool::managed::PoolConfig::default(),
59+
};
60+
3461
WalletPool::builder(manager)
35-
.config(deadpool::managed::PoolConfig::default())
62+
.config(dp_config)
3663
.build()
3764
.map_err(|e| ErrorKind::Generic.context(e).into())
3865
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
//! Test utilities for database operations.
2+
3+
use zcash_client_sqlite::wallet::init::WalletMigrator;
4+
use zcash_protocol::consensus::Parameters;
5+
6+
use super::{DbHandle, all_external_migrations, connection};
7+
use crate::{error::Error, network::Network};
8+
9+
/// Creates an in-memory database pool for testing.
10+
///
11+
/// Uses pool size 2 to allow concurrent access from both the wallet handle
12+
/// and the keystore (which needs its own handle for database operations).
13+
///
14+
/// Uses a shared in-memory database URI so all connections in the pool
15+
/// access the same database.
16+
pub(crate) fn test_pool(params: Network) -> Result<connection::WalletPool, Error> {
17+
// Use a shared in-memory database with a unique name per pool
18+
// The `cache=shared` mode allows multiple connections to share the same database
19+
// The unique name ensures different tests don't interfere with each other
20+
use std::sync::atomic::{AtomicU64, Ordering};
21+
static COUNTER: AtomicU64 = AtomicU64::new(0);
22+
let db_name = format!(
23+
"file:zallet_test_{}?mode=memory&cache=shared",
24+
COUNTER.fetch_add(1, Ordering::SeqCst)
25+
);
26+
27+
connection::pool(
28+
&db_name,
29+
params,
30+
Some(connection::PoolConfig { max_size: 2 }),
31+
)
32+
}
33+
34+
/// A test database wrapper that provides access to the pool and handles.
35+
///
36+
/// This wraps an in-memory SQLite database with all migrations applied,
37+
/// suitable for unit and integration tests.
38+
pub(crate) struct TestDatabase {
39+
pool: connection::WalletPool,
40+
params: Network,
41+
}
42+
43+
impl TestDatabase {
44+
/// Creates a new in-memory test database with migrations applied.
45+
pub(crate) async fn new(params: Network) -> Result<Self, Error> {
46+
let pool = test_pool(params)?;
47+
let db = Self { pool, params };
48+
db.init().await?;
49+
Ok(db)
50+
}
51+
52+
/// Initializes the database schema by applying all migrations.
53+
async fn init(&self) -> Result<(), Error> {
54+
let handle = self.handle().await?;
55+
let params = self.params;
56+
handle.with_mut(|mut db_data| {
57+
WalletMigrator::new()
58+
.with_external_migrations(all_external_migrations(params.network_type()))
59+
.init_or_migrate(&mut db_data)
60+
.map_err(|e| crate::error::ErrorKind::Init.context(e))
61+
})?;
62+
Ok(())
63+
}
64+
65+
/// Gets a database handle from the pool.
66+
pub(crate) async fn handle(&self) -> Result<DbHandle, Error> {
67+
self.pool
68+
.get()
69+
.await
70+
.map_err(|e| crate::error::ErrorKind::Generic.context(e).into())
71+
}
72+
73+
/// Returns the network parameters.
74+
#[allow(dead_code)]
75+
pub(crate) fn params(&self) -> Network {
76+
self.params
77+
}
78+
79+
/// Returns a reference to the underlying pool.
80+
///
81+
/// This is useful for creating a `Database` wrapper for KeyStore.
82+
pub(crate) fn pool(&self) -> &connection::WalletPool {
83+
&self.pool
84+
}
85+
}

zallet/src/components/json_rpc/methods/list_accounts.rs

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,3 +160,123 @@ pub(super) fn account_details<T>(
160160

161161
Ok(f(name, seedfp, account, addresses))
162162
}
163+
164+
#[cfg(test)]
165+
mod tests {
166+
mod integration {
167+
use zcash_protocol::consensus;
168+
169+
use crate::{components::testing::TestWallet, network::Network};
170+
171+
use super::super::*;
172+
173+
/// Test z_listaccounts returns empty list for a wallet with no accounts.
174+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
175+
async fn list_accounts_empty_wallet() {
176+
let network = Network::Consensus(consensus::Network::MainNetwork);
177+
let wallet = TestWallet::new(network).await.unwrap();
178+
179+
let handle = wallet.handle().await.unwrap();
180+
181+
let result = call(handle.as_ref(), Some(true));
182+
183+
assert!(result.is_ok());
184+
let accounts = result.unwrap();
185+
assert!(accounts.0.is_empty(), "Expected empty account list");
186+
}
187+
188+
/// Test z_listaccounts returns a single account correctly.
189+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
190+
async fn list_accounts_single_account() {
191+
let network = Network::Consensus(consensus::Network::MainNetwork);
192+
let wallet = TestWallet::new(network).await.unwrap();
193+
194+
let account = wallet
195+
.account_builder()
196+
.with_name("my_account")
197+
.build()
198+
.await
199+
.unwrap();
200+
201+
let handle = wallet.handle().await.unwrap();
202+
203+
let result = call(handle.as_ref(), Some(true));
204+
205+
assert!(result.is_ok());
206+
let accounts = result.unwrap();
207+
assert_eq!(accounts.0.len(), 1, "Expected exactly one account");
208+
209+
let listed = &accounts.0[0];
210+
assert_eq!(
211+
listed.account_uuid,
212+
account.account_id.expose_uuid().to_string()
213+
);
214+
assert_eq!(listed.name.as_deref(), Some("my_account"));
215+
assert!(listed.seedfp.is_some(), "Should have seed fingerprint");
216+
}
217+
218+
/// Test z_listaccounts returns multiple accounts correctly.
219+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
220+
async fn list_accounts_multiple_accounts() {
221+
let network = Network::Consensus(consensus::Network::MainNetwork);
222+
let wallet = TestWallet::new(network).await.unwrap();
223+
224+
let account1 = wallet
225+
.account_builder()
226+
.with_name("first_account")
227+
.build()
228+
.await
229+
.unwrap();
230+
231+
let account2 = wallet
232+
.account_builder()
233+
.with_name("second_account")
234+
.build()
235+
.await
236+
.unwrap();
237+
238+
let handle = wallet.handle().await.unwrap();
239+
240+
let result = call(handle.as_ref(), Some(true));
241+
242+
assert!(result.is_ok());
243+
let accounts = result.unwrap();
244+
assert_eq!(accounts.0.len(), 2, "Expected exactly two accounts");
245+
246+
// Verify both accounts are present (order may vary)
247+
let uuids: Vec<_> = accounts.0.iter().map(|a| a.account_uuid.as_str()).collect();
248+
assert!(uuids.contains(&account1.account_id.expose_uuid().to_string().as_str()));
249+
assert!(uuids.contains(&account2.account_id.expose_uuid().to_string().as_str()));
250+
251+
// Verify names are present
252+
let names: Vec<_> = accounts
253+
.0
254+
.iter()
255+
.filter_map(|a| a.name.as_deref())
256+
.collect();
257+
assert!(names.contains(&"first_account"));
258+
assert!(names.contains(&"second_account"));
259+
}
260+
261+
/// Test z_listaccounts with include_addresses=false omits addresses.
262+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
263+
async fn list_accounts_without_addresses() {
264+
let network = Network::Consensus(consensus::Network::MainNetwork);
265+
let wallet = TestWallet::new(network).await.unwrap();
266+
267+
let _account = wallet.account_builder().build().await.unwrap();
268+
269+
let handle = wallet.handle().await.unwrap();
270+
271+
let result = call(handle.as_ref(), Some(false));
272+
273+
assert!(result.is_ok());
274+
let accounts = result.unwrap();
275+
assert_eq!(accounts.0.len(), 1);
276+
assert!(
277+
accounts.0[0].addresses.is_none(),
278+
"Addresses should be omitted when include_addresses is false"
279+
);
280+
}
281+
}
282+
}

0 commit comments

Comments
 (0)