diff --git a/khbb_lib/Cargo.toml b/khbb_lib/Cargo.toml index 5a8b868..9eff49b 100644 --- a/khbb_lib/Cargo.toml +++ b/khbb_lib/Cargo.toml @@ -38,3 +38,4 @@ tracing.workspace = true tracing-subscriber.workspace = true yellowstone-grpc-client.workspace = true yellowstone-grpc-proto.workspace = true +uuid.workspace = true diff --git a/khbb_lib/src/config.rs b/khbb_lib/src/config.rs index 335ed27..b4b06e7 100644 --- a/khbb_lib/src/config.rs +++ b/khbb_lib/src/config.rs @@ -83,3 +83,98 @@ impl KhbbAppConfig { Ok(()) } } + +#[cfg(test)] +mod tests { + fn build_valid_config() -> crate::KhbbAppConfig { + crate::KhbbAppConfig { + database_url: std::string::String::from("sqlite://./dbdata/app.db"), + solana_http_rpc_url: std::string::String::from( + "https://mainnet.helius-rpc.com/?api-key=test", + ), + solana_ws_rpc_url: std::string::String::from( + "wss://mainnet.helius-rpc.com/?api-key=test", + ), + yellowstone_grpc_url: Some(std::string::String::from( + "https://mainnet.helius-rpc.com:443", + )), + log_filter: std::string::String::from("info"), + bootstrap_database: true, + listener_poll_interval_ms: 1000, + } + } + + #[test] + fn validate_accepts_valid_config() { + let config = build_valid_config(); + let result = config.validate(); + assert!(result.is_ok()); + } + + #[test] + fn validate_rejects_empty_database_url() { + let mut config = build_valid_config(); + config.database_url = std::string::String::new(); + let result = config.validate(); + assert!(result.is_err()); + } + + #[test] + fn validate_rejects_empty_http_rpc_url() { + let mut config = build_valid_config(); + config.solana_http_rpc_url = std::string::String::new(); + let result = config.validate(); + assert!(result.is_err()); + } + + #[test] + fn validate_rejects_empty_ws_rpc_url() { + let mut config = build_valid_config(); + config.solana_ws_rpc_url = std::string::String::new(); + let result = config.validate(); + assert!(result.is_err()); + } + + #[test] + fn validate_rejects_empty_log_filter() { + let mut config = build_valid_config(); + config.log_filter = std::string::String::new(); + let result = config.validate(); + assert!(result.is_err()); + } + + #[test] + fn validate_rejects_zero_poll_interval() { + let mut config = build_valid_config(); + config.listener_poll_interval_ms = 0; + let result = config.validate(); + assert!(result.is_err()); + } + + #[tokio::test] + async fn load_from_json_file_loads_valid_json() { + let temp_dir = + std::env::temp_dir().join(std::format!("khbb_config_test_{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&temp_dir).expect("create temp dir"); + let config_path = temp_dir.join("config.json"); + + let config_json = r#"{ + "database_url": "sqlite://./dbdata/app.db", + "solana_http_rpc_url": "https://mainnet.helius-rpc.com/?api-key=test", + "solana_ws_rpc_url": "wss://mainnet.helius-rpc.com/?api-key=test", + "yellowstone_grpc_url": "https://mainnet.helius-rpc.com:443", + "log_filter": "info", + "bootstrap_database": true, + "listener_poll_interval_ms": 1000 +}"#; + + std::fs::write(&config_path, config_json).expect("write config file"); + let result = crate::KhbbAppConfig::load_from_json_file( + config_path.to_str().expect("config path to str"), + ) + .await; + assert!(result.is_ok()); + let _ = std::fs::remove_file(&config_path); + let _ = std::fs::remove_dir_all(&temp_dir); + } +} diff --git a/khbb_lib/src/storage.rs b/khbb_lib/src/storage.rs index 47b82b1..52a13f8 100644 --- a/khbb_lib/src/storage.rs +++ b/khbb_lib/src/storage.rs @@ -2,47 +2,59 @@ //! SQLite storage bootstrap and persistence helpers. +fn extract_sqlite_file_path( + database_url: &str, +) -> core::result::Result, crate::KhbbError> { + if database_url == "sqlite::memory:" { + return Ok(None); + } + if let Some(value) = database_url.strip_prefix("sqlite://") { + return Ok(Some(std::path::PathBuf::from(value))); + } + if let Some(value) = database_url.strip_prefix("sqlite:") { + return Ok(Some(std::path::PathBuf::from(value))); + } + Err(crate::KhbbError::Config { + message: std::format!("invalid sqlite database url `{database_url}`"), + }) +} + /// Creates a SQLite pool for the khbb runtime. pub async fn create_sqlite_pool( database_url: &str, ) -> core::result::Result { - let sqlite_path = if let Some(value) = database_url.strip_prefix("sqlite://") { - value - } else if let Some(value) = database_url.strip_prefix("sqlite:") { - value - } else { - "" + let sqlite_path_result = extract_sqlite_file_path(database_url); + let sqlite_path = match sqlite_path_result { + Ok(value) => value, + Err(error) => { + return Err(error); + }, }; - if !sqlite_path.is_empty() { - let path = std::path::Path::new(sqlite_path); + if let Some(path) = sqlite_path { if let Some(parent) = path.parent() { if !parent.as_os_str().is_empty() { let create_dir_result = tokio::fs::create_dir_all(parent).await; match create_dir_result { - Ok(()) => {} + Ok(()) => {}, Err(error) => { return Err(crate::KhbbError::Io { context: "create sqlite parent directory", message: error.to_string(), }); - } + }, } } } } - let parse_result = ::from_str( - database_url, - ); + let parse_result = + ::from_str(database_url); let connect_options = match parse_result { Ok(value) => value.create_if_missing(true), Err(error) => { return Err(crate::KhbbError::Config { - message: std::format!( - "invalid sqlite database url `{database_url}`: {}", - error - ), + message: std::format!("invalid sqlite database url `{database_url}`: {}", error), }); - } + }, }; let connect_result = sqlx::sqlite::SqlitePoolOptions::new() .max_connections(1) @@ -266,3 +278,130 @@ WHERE id = ?2; }), } } + +#[cfg(test)] +mod tests { + fn build_test_config(database_url: std::string::String) -> crate::KhbbAppConfig { + crate::KhbbAppConfig { + database_url, + solana_http_rpc_url: std::string::String::from( + "https://mainnet.helius-rpc.com/?api-key=test", + ), + solana_ws_rpc_url: std::string::String::from( + "wss://mainnet.helius-rpc.com/?api-key=test", + ), + yellowstone_grpc_url: Some(std::string::String::from( + "https://mainnet.helius-rpc.com:443", + )), + log_filter: std::string::String::from("info"), + bootstrap_database: true, + listener_poll_interval_ms: 1000, + } + } + + fn build_temp_sqlite_url() -> std::string::String { + let temp_dir = + std::env::temp_dir().join(std::format!("khbb_storage_test_{}", uuid::Uuid::new_v4())); + let db_path = temp_dir.join("app.db"); + std::format!("sqlite://{}", db_path.to_string_lossy()) + } + + #[test] + fn extract_sqlite_file_path_accepts_memory_url() { + let result = super::extract_sqlite_file_path("sqlite::memory:"); + assert!(result.is_ok()); + let path = result.expect("extract memory path"); + assert!(path.is_none()); + } + + #[test] + fn extract_sqlite_file_path_accepts_file_url() { + let result = super::extract_sqlite_file_path("sqlite://./dbdata/app.db"); + assert!(result.is_ok()); + let path = result.expect("extract file path"); + assert!(path.is_some()); + } + + #[test] + fn extract_sqlite_file_path_rejects_invalid_scheme() { + let result = super::extract_sqlite_file_path("postgres://localhost/test"); + assert!(result.is_err()); + } + + #[tokio::test] + async fn create_sqlite_pool_creates_file_database() { + let database_url = build_temp_sqlite_url(); + let pool_result = crate::create_sqlite_pool(&database_url).await; + assert!(pool_result.is_ok()); + let pool = pool_result.expect("create sqlite pool"); + let ping_result = sqlx::query("SELECT 1;").execute(&pool).await; + assert!(ping_result.is_ok()); + } + + #[tokio::test] + async fn ensure_sqlite_schema_creates_tables() { + let database_url = build_temp_sqlite_url(); + let pool_result = crate::create_sqlite_pool(&database_url).await; + assert!(pool_result.is_ok()); + let pool = pool_result.expect("create sqlite pool"); + let schema_result = crate::ensure_sqlite_schema(&pool).await; + assert!(schema_result.is_ok()); + let query_result = sqlx::query( + r#" +SELECT name +FROM sqlite_master +WHERE type = 'table' + AND name = 'listener_sessions'; +"#, + ) + .fetch_one(&pool) + .await; + assert!(query_result.is_ok()); + } + + #[tokio::test] + async fn insert_listener_session_inserts_row() { + let database_url = build_temp_sqlite_url(); + let pool_result = crate::create_sqlite_pool(&database_url).await; + assert!(pool_result.is_ok()); + let pool = pool_result.expect("create sqlite pool"); + let schema_result = crate::ensure_sqlite_schema(&pool).await; + assert!(schema_result.is_ok()); + let config = build_test_config(database_url); + let insert_result = super::insert_listener_session(&pool, &config).await; + assert!(insert_result.is_ok()); + let session = insert_result.expect("insert listener session"); + assert!(session.id > 0); + assert_eq!(session.status, "running"); + } + + #[tokio::test] + async fn update_listener_session_status_updates_row() { + let database_url = build_temp_sqlite_url(); + let pool_result = crate::create_sqlite_pool(&database_url).await; + assert!(pool_result.is_ok()); + let pool = pool_result.expect("create sqlite pool"); + let schema_result = crate::ensure_sqlite_schema(&pool).await; + assert!(schema_result.is_ok()); + let config = build_test_config(database_url); + let insert_result = super::insert_listener_session(&pool, &config).await; + assert!(insert_result.is_ok()); + let session = insert_result.expect("insert listener session"); + let update_result = + super::update_listener_session_status(&pool, session.id, "stopped").await; + assert!(update_result.is_ok()); + let fetch_result = sqlx::query_scalar::<_, std::string::String>( + r#" +SELECT status +FROM listener_sessions +WHERE id = ?1; +"#, + ) + .bind(session.id) + .fetch_one(&pool) + .await; + assert!(fetch_result.is_ok()); + let status = fetch_result.expect("fetch updated status"); + assert_eq!(status, "stopped"); + } +}