Skip to content
Draft
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
4 changes: 4 additions & 0 deletions zallet/src/components.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ pub(crate) mod tracing;
#[cfg(zallet_build = "wallet")]
pub(crate) mod keystore;

#[cfg(test)]
#[cfg(zallet_build = "wallet")]
pub(crate) mod testing;

/// A handle to a background task spawned by a component.
///
/// Background tasks in Zallet are either one-shot (expected to terminate before Zallet),
Expand Down
14 changes: 13 additions & 1 deletion zallet/src/components/database.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ mod ext;
#[cfg(test)]
mod tests;

#[cfg(test)]
pub(crate) mod testing;

pub(crate) type DbHandle = deadpool::managed::Object<connection::WalletManager>;

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

impl Database {
/// Creates a Database from an existing pool.
///
/// Note: This does NOT apply migrations. Caller must ensure
/// the database is already initialized.
#[cfg(test)]
pub(crate) fn from_pool(pool: connection::WalletPool) -> Self {
Self { db_data_pool: pool }
}

pub(crate) async fn open(config: &ZalletConfig) -> Result<Self, Error> {
let path = config.wallet_db_path();

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

let db_data_pool = connection::pool(&path, config.consensus.network())?;
let db_data_pool = connection::pool(&path, config.consensus.network(), None)?;

let database = Self { db_data_pool };

Expand Down
31 changes: 29 additions & 2 deletions zallet/src/components/database/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,38 @@ use crate::{
network::Network,
};

pub(super) fn pool(path: impl AsRef<Path>, params: Network) -> Result<WalletPool, Error> {
/// Configuration for the database connection pool.
#[derive(Clone, Debug)]
pub(crate) struct PoolConfig {
/// Maximum number of connections in the pool.
pub max_size: usize,
}

impl Default for PoolConfig {
fn default() -> Self {
// Default deadpool size is 16
Self { max_size: 16 }
}
}

pub(super) fn pool(
path: impl AsRef<Path>,
params: Network,
pool_config: Option<PoolConfig>,
) -> Result<WalletPool, Error> {
let config = deadpool_sqlite::Config::new(path.as_ref());
let manager = WalletManager::from_config(&config, params);

let dp_config = match pool_config {
Some(cfg) => deadpool::managed::PoolConfig {
max_size: cfg.max_size,
..deadpool::managed::PoolConfig::default()
},
None => deadpool::managed::PoolConfig::default(),
};

WalletPool::builder(manager)
.config(deadpool::managed::PoolConfig::default())
.config(dp_config)
.build()
.map_err(|e| ErrorKind::Generic.context(e).into())
}
Expand Down
85 changes: 85 additions & 0 deletions zallet/src/components/database/testing.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
//! Test utilities for database operations.

use zcash_client_sqlite::wallet::init::WalletMigrator;
use zcash_protocol::consensus::Parameters;

use super::{DbHandle, all_external_migrations, connection};
use crate::{error::Error, network::Network};

/// Creates an in-memory database pool for testing.
///
/// Uses pool size 2 to allow concurrent access from both the wallet handle
/// and the keystore (which needs its own handle for database operations).
///
/// Uses a shared in-memory database URI so all connections in the pool
/// access the same database.
pub(crate) fn test_pool(params: Network) -> Result<connection::WalletPool, Error> {
// Use a shared in-memory database with a unique name per pool
// The `cache=shared` mode allows multiple connections to share the same database
// The unique name ensures different tests don't interfere with each other
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let db_name = format!(
"file:zallet_test_{}?mode=memory&cache=shared",
COUNTER.fetch_add(1, Ordering::SeqCst)
);

connection::pool(
&db_name,
params,
Some(connection::PoolConfig { max_size: 2 }),
)
}

/// A test database wrapper that provides access to the pool and handles.
///
/// This wraps an in-memory SQLite database with all migrations applied,
/// suitable for unit and integration tests.
pub(crate) struct TestDatabase {
pool: connection::WalletPool,
params: Network,
}

impl TestDatabase {
/// Creates a new in-memory test database with migrations applied.
pub(crate) async fn new(params: Network) -> Result<Self, Error> {
let pool = test_pool(params)?;
let db = Self { pool, params };
db.init().await?;
Ok(db)
}

/// Initializes the database schema by applying all migrations.
async fn init(&self) -> Result<(), Error> {
let handle = self.handle().await?;
let params = self.params;
handle.with_mut(|mut db_data| {
WalletMigrator::new()
.with_external_migrations(all_external_migrations(params.network_type()))
.init_or_migrate(&mut db_data)
.map_err(|e| crate::error::ErrorKind::Init.context(e))
})?;
Ok(())
}

/// Gets a database handle from the pool.
pub(crate) async fn handle(&self) -> Result<DbHandle, Error> {
self.pool
.get()
.await
.map_err(|e| crate::error::ErrorKind::Generic.context(e).into())
}

/// Returns the network parameters.
#[allow(dead_code)]
pub(crate) fn params(&self) -> Network {
self.params
}

/// Returns a reference to the underlying pool.
///
/// This is useful for creating a `Database` wrapper for KeyStore.
pub(crate) fn pool(&self) -> &connection::WalletPool {
&self.pool
}
}
120 changes: 120 additions & 0 deletions zallet/src/components/json_rpc/methods/list_accounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,3 +160,123 @@ pub(super) fn account_details<T>(

Ok(f(name, seedfp, account, addresses))
}

#[cfg(test)]
mod tests {
mod integration {
use zcash_protocol::consensus;

use crate::{components::testing::TestWallet, network::Network};

use super::super::*;

/// Test z_listaccounts returns empty list for a wallet with no accounts.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn list_accounts_empty_wallet() {
let network = Network::Consensus(consensus::Network::MainNetwork);
let wallet = TestWallet::new(network).await.unwrap();

let handle = wallet.handle().await.unwrap();

let result = call(handle.as_ref(), Some(true));

assert!(result.is_ok());
let accounts = result.unwrap();
assert!(accounts.0.is_empty(), "Expected empty account list");
}

/// Test z_listaccounts returns a single account correctly.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn list_accounts_single_account() {
let network = Network::Consensus(consensus::Network::MainNetwork);
let wallet = TestWallet::new(network).await.unwrap();

let account = wallet
.account_builder()
.with_name("my_account")
.build()
.await
.unwrap();

let handle = wallet.handle().await.unwrap();

let result = call(handle.as_ref(), Some(true));

assert!(result.is_ok());
let accounts = result.unwrap();
assert_eq!(accounts.0.len(), 1, "Expected exactly one account");

let listed = &accounts.0[0];
assert_eq!(
listed.account_uuid,
account.account_id.expose_uuid().to_string()
);
assert_eq!(listed.name.as_deref(), Some("my_account"));
assert!(listed.seedfp.is_some(), "Should have seed fingerprint");
}

/// Test z_listaccounts returns multiple accounts correctly.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn list_accounts_multiple_accounts() {
let network = Network::Consensus(consensus::Network::MainNetwork);
let wallet = TestWallet::new(network).await.unwrap();

let account1 = wallet
.account_builder()
.with_name("first_account")
.build()
.await
.unwrap();

let account2 = wallet
.account_builder()
.with_name("second_account")
.build()
.await
.unwrap();

let handle = wallet.handle().await.unwrap();

let result = call(handle.as_ref(), Some(true));

assert!(result.is_ok());
let accounts = result.unwrap();
assert_eq!(accounts.0.len(), 2, "Expected exactly two accounts");

// Verify both accounts are present (order may vary)
let uuids: Vec<_> = accounts.0.iter().map(|a| a.account_uuid.as_str()).collect();
assert!(uuids.contains(&account1.account_id.expose_uuid().to_string().as_str()));
assert!(uuids.contains(&account2.account_id.expose_uuid().to_string().as_str()));

// Verify names are present
let names: Vec<_> = accounts
.0
.iter()
.filter_map(|a| a.name.as_deref())
.collect();
assert!(names.contains(&"first_account"));
assert!(names.contains(&"second_account"));
}

/// Test z_listaccounts with include_addresses=false omits addresses.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn list_accounts_without_addresses() {
let network = Network::Consensus(consensus::Network::MainNetwork);
let wallet = TestWallet::new(network).await.unwrap();

let _account = wallet.account_builder().build().await.unwrap();

let handle = wallet.handle().await.unwrap();

let result = call(handle.as_ref(), Some(false));

assert!(result.is_ok());
let accounts = result.unwrap();
assert_eq!(accounts.0.len(), 1);
assert!(
accounts.0[0].addresses.is_none(),
"Addresses should be omitted when include_addresses is false"
);
}
}
}
Loading