This commit is contained in:
2026-04-17 19:34:37 +02:00
parent 09838c4dd7
commit 71a8cb7186
3 changed files with 253 additions and 18 deletions

View File

@@ -38,3 +38,4 @@ tracing.workspace = true
tracing-subscriber.workspace = true
yellowstone-grpc-client.workspace = true
yellowstone-grpc-proto.workspace = true
uuid.workspace = true

View File

@@ -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);
}
}

View File

@@ -2,47 +2,59 @@
//! 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.
pub async fn create_sqlite_pool(
database_url: &str,
) -> core::result::Result<sqlx::SqlitePool, crate::KhbbError> {
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 = <sqlx::sqlite::SqliteConnectOptions as std::str::FromStr>::from_str(
database_url,
);
let parse_result =
<sqlx::sqlite::SqliteConnectOptions as std::str::FromStr>::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");
}
}