use mongodb::bson::doc;
use std::fmt::Debug;
use std::sync::Arc;
use crate::infrastructure::config::mongo::MongoDBConfig;
use crate::infrastructure::config::{redact_uri, redact_userinfo};
use crate::utils::ChainError;
use mongodb::options::ClientOptions;
use serde::Serialize;
use serde::de::DeserializeOwned;
use tokio::sync::Mutex;
use tracing::{debug, info, instrument};
use uuid::Uuid;
pub struct MongoDBClient {
db: Arc<Mutex<mongodb::Database>>,
config: MongoDBConfig,
}
impl MongoDBClient {
#[instrument(skip(config), level = "debug")]
pub async fn new(config: MongoDBConfig) -> Result<Self, ChainError> {
info!("Connecting to MongoDB at {}", redact_uri(&config.uri));
let mut client_options = ClientOptions::parse(&config.uri).await.map_err(|e| {
ChainError::Internal(redact_userinfo(&format!(
"Failed to parse MongoDB URI: {}",
e
)))
})?;
client_options.app_name = Some("OptionChain-Simulator".to_string());
let timeout = std::time::Duration::from_secs(config.timeout);
client_options.connect_timeout = Some(timeout);
client_options.server_selection_timeout = Some(timeout);
let client = mongodb::Client::with_options(client_options).map_err(|e| {
ChainError::Internal(redact_userinfo(&format!(
"Failed to create MongoDB client: {}",
e
)))
})?;
client
.database("admin")
.run_command(doc! {"ping": 1})
.await
.map_err(|e| {
ChainError::Internal(redact_userinfo(&format!("Failed to ping MongoDB: {}", e)))
})?;
info!("Successfully connected to MongoDB");
let db = client.database(&config.database);
Ok(Self {
db: Arc::new(Mutex::new(db)),
config,
})
}
async fn steps_collection<T>(&self) -> mongodb::Collection<T>
where
T: Sync + Send + Serialize + DeserializeOwned,
{
let db = self.db.lock().await;
let config = self.get_config();
db.collection(&config.steps_collection)
}
async fn events_collection<T>(&self) -> mongodb::Collection<T>
where
T: Sync + Send + Serialize + DeserializeOwned,
{
let db = self.db.lock().await;
let config = self.get_config();
db.collection(&config.events_collection)
}
#[instrument(skip(self, step), level = "debug")]
pub async fn save_step<T>(&self, session_id: Uuid, step: T) -> Result<(), ChainError>
where
T: Sync + Send + Serialize + DeserializeOwned + Debug,
{
let collection = self.steps_collection::<T>().await;
debug!(session_id = %session_id, "Saving step to MongoDB");
let result = collection
.insert_one(step)
.await
.map_err(|e| ChainError::Internal(format!("Failed to save step to MongoDB: {}", e)))?;
debug!(
session_id = %session_id,
insert_id = %result.inserted_id,
"Step saved successfully"
);
Ok(())
}
#[instrument(skip(self, event), level = "debug")]
pub async fn save_event<T>(&self, session_id: Uuid, event: T) -> Result<(), ChainError>
where
T: Sync + Send + Serialize + DeserializeOwned + Debug,
{
let collection = self.events_collection::<T>().await;
debug!(session_id = %session_id, "Saving event to MongoDB");
let result = collection
.insert_one(event)
.await
.map_err(|e| ChainError::Internal(format!("Failed to save event to MongoDB: {}", e)))?;
debug!(
session_id = %session_id,
insert_id = %result.inserted_id,
"Event saved successfully"
);
Ok(())
}
pub fn get_config(&self) -> &MongoDBConfig {
&self.config
}
}
#[cfg(test)]
mod tests {
use crate::infrastructure::config::mongo::MongoDBConfig;
use crate::infrastructure::mongodb::MongoDBClient;
use std::sync::Arc;
use tokio::test;
#[test]
#[ignore = "requires live MongoDB on localhost:27017; run with -- --ignored"]
async fn test_mongodb_client_initialization() {
let config = MongoDBConfig {
uri: "mongodb://localhost:27017".to_string(),
database: "test_db".to_string(),
steps_collection: "test_steps".to_string(),
events_collection: "test_events".to_string(),
timeout: 5,
};
let client_result = MongoDBClient::new(config.clone()).await;
assert!(
client_result.is_ok(),
"Failed to create MongoDB client: {:?}",
client_result.err()
);
let client = client_result.unwrap();
assert_eq!(client.get_config().database, "test_db");
}
#[test]
async fn test_client_options_parse_without_server() {
let config = MongoDBConfig {
uri: "mongodb://localhost:27017".to_string(),
database: "test_db".to_string(),
steps_collection: "test_steps".to_string(),
events_collection: "test_events".to_string(),
timeout: 5,
};
let options = mongodb::options::ClientOptions::parse(&config.uri).await;
assert!(options.is_ok(), "a well-formed URI must parse");
assert_eq!(config.database, "test_db");
assert_eq!(config.timeout, 5);
}
#[test]
async fn test_unavailable_server_fails_within_configured_timeout() {
let config = MongoDBConfig {
uri: "mongodb://localhost:1".to_string(),
database: "test_db".to_string(),
steps_collection: "test_steps".to_string(),
events_collection: "test_events".to_string(),
timeout: 1,
};
let start = std::time::Instant::now();
let result = MongoDBClient::new(config).await;
let elapsed = start.elapsed();
assert!(result.is_err(), "connection to a closed port must fail");
assert!(
elapsed < std::time::Duration::from_secs(5),
"startup failure took {elapsed:?}; the 1s configured timeout must \
bound server selection, not the ~30s driver default"
);
}
#[test]
#[ignore = "requires a live MongoDB matching MONGODB_URI; run with -- --ignored"]
async fn test_init_mongodb() {
let repo = crate::infrastructure::repositories::mongo_repo::init_mongodb()
.await
.expect("init_mongodb must succeed against the provisioned live MongoDB");
assert!(Arc::strong_count(&repo) >= 1);
}
}