0.3.0
This commit is contained in:
@@ -38,3 +38,4 @@ tracing.workspace = true
|
|||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
yellowstone-grpc-client.workspace = true
|
yellowstone-grpc-client.workspace = true
|
||||||
yellowstone-grpc-proto.workspace = true
|
yellowstone-grpc-proto.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
|||||||
@@ -83,3 +83,98 @@ impl KhbbAppConfig {
|
|||||||
Ok(())
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,47 +2,59 @@
|
|||||||
|
|
||||||
//! SQLite storage bootstrap and persistence helpers.
|
//! SQLite storage bootstrap and persistence helpers.
|
||||||
|
|
||||||
|
fn extract_sqlite_file_path(
|
||||||
|
database_url: &str,
|
||||||
|
) -> core::result::Result<std::option::Option<std::path::PathBuf>, 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.
|
/// Creates a SQLite pool for the khbb runtime.
|
||||||
pub async fn create_sqlite_pool(
|
pub async fn create_sqlite_pool(
|
||||||
database_url: &str,
|
database_url: &str,
|
||||||
) -> core::result::Result<sqlx::SqlitePool, crate::KhbbError> {
|
) -> core::result::Result<sqlx::SqlitePool, crate::KhbbError> {
|
||||||
let sqlite_path = if let Some(value) = database_url.strip_prefix("sqlite://") {
|
let sqlite_path_result = extract_sqlite_file_path(database_url);
|
||||||
value
|
let sqlite_path = match sqlite_path_result {
|
||||||
} else if let Some(value) = database_url.strip_prefix("sqlite:") {
|
Ok(value) => value,
|
||||||
value
|
Err(error) => {
|
||||||
} else {
|
return Err(error);
|
||||||
""
|
},
|
||||||
};
|
};
|
||||||
if !sqlite_path.is_empty() {
|
if let Some(path) = sqlite_path {
|
||||||
let path = std::path::Path::new(sqlite_path);
|
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
if !parent.as_os_str().is_empty() {
|
if !parent.as_os_str().is_empty() {
|
||||||
let create_dir_result = tokio::fs::create_dir_all(parent).await;
|
let create_dir_result = tokio::fs::create_dir_all(parent).await;
|
||||||
match create_dir_result {
|
match create_dir_result {
|
||||||
Ok(()) => {}
|
Ok(()) => {},
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
return Err(crate::KhbbError::Io {
|
return Err(crate::KhbbError::Io {
|
||||||
context: "create sqlite parent directory",
|
context: "create sqlite parent directory",
|
||||||
message: error.to_string(),
|
message: error.to_string(),
|
||||||
});
|
});
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
let parse_result =
|
||||||
let parse_result = <sqlx::sqlite::SqliteConnectOptions as std::str::FromStr>::from_str(
|
<sqlx::sqlite::SqliteConnectOptions as std::str::FromStr>::from_str(database_url);
|
||||||
database_url,
|
|
||||||
);
|
|
||||||
let connect_options = match parse_result {
|
let connect_options = match parse_result {
|
||||||
Ok(value) => value.create_if_missing(true),
|
Ok(value) => value.create_if_missing(true),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
return Err(crate::KhbbError::Config {
|
return Err(crate::KhbbError::Config {
|
||||||
message: std::format!(
|
message: std::format!("invalid sqlite database url `{database_url}`: {}", error),
|
||||||
"invalid sqlite database url `{database_url}`: {}",
|
|
||||||
error
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
let connect_result = sqlx::sqlite::SqlitePoolOptions::new()
|
let connect_result = sqlx::sqlite::SqlitePoolOptions::new()
|
||||||
.max_connections(1)
|
.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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user