Files
foxhunt/testing/harness/fixtures.rs
jgrusewski 9c3d741a08 refactor: restructure repo — crates/, bin/, testing/ layout
Move 17 library crates into crates/, CLI binary into bin/fxt,
consolidate 10 test crates into testing/, split config crate
from deployment config files.

Root directory reduced from 38+ to ~17 directories.
All Cargo.toml paths and build.rs proto refs updated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:56:00 +01:00

517 lines
17 KiB
Rust

//! Database and Service Fixtures for Integration Testing
//!
//! Manages test database setup, cleanup, and service lifecycle
//! for reproducible integration testing.
use anyhow::Result;
use std::process::Command;
use std::time::Duration;
use tokio::time::timeout;
/// Test fixtures manager for database and service setup
pub struct TestFixtures {
postgres_container: Option<String>,
influxdb_container: Option<String>,
redis_container: Option<String>,
}
impl TestFixtures {
/// Create new test fixtures manager
pub fn new() -> Result<Self> {
Ok(Self {
postgres_container: None,
influxdb_container: None,
redis_container: None,
})
}
/// Setup all test databases and services
pub async fn setup(&mut self) -> Result<()> {
println!("Setting up test fixtures...");
// Start PostgreSQL container
self.setup_postgres().await?;
// Start InfluxDB container
self.setup_influxdb().await?;
// Start Redis container
self.setup_redis().await?;
// Wait for all services to be ready
self.wait_for_services().await?;
// Initialize database schemas
self.initialize_schemas().await?;
println!("Test fixtures setup complete");
Ok(())
}
/// Cleanup all test resources
pub async fn cleanup(&mut self) -> Result<()> {
println!("Cleaning up test fixtures...");
// Stop and remove containers
if let Some(container_id) = &self.postgres_container {
self.stop_container(container_id).await?;
}
if let Some(container_id) = &self.influxdb_container {
self.stop_container(container_id).await?;
}
if let Some(container_id) = &self.redis_container {
self.stop_container(container_id).await?;
}
// Clean up test data directories
tokio::fs::remove_dir_all("/tmp/test_data").await.ok();
tokio::fs::remove_dir_all("/tmp/test_models").await.ok();
println!("Test fixtures cleanup complete");
Ok(())
}
/// Setup PostgreSQL test database
async fn setup_postgres(&mut self) -> Result<()> {
println!("Starting PostgreSQL container...");
let output = Command::new("docker")
.args([
"run", "-d",
"--name", "foxhunt-test-postgres",
"-e", "POSTGRES_DB=foxhunt_test",
"-e", "POSTGRES_USER=test",
"-e", "POSTGRES_PASSWORD=test",
"-p", "5432:5432",
"--rm",
"postgres:15-alpine"
])
.output()?;
if !output.status.success() {
return Err(anyhow::anyhow!(
"Failed to start PostgreSQL container: {}",
String::from_utf8_lossy(&output.stderr)
));
}
let container_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
self.postgres_container = Some(container_id);
println!("PostgreSQL container started");
Ok(())
}
/// Setup InfluxDB test database
async fn setup_influxdb(&mut self) -> Result<()> {
println!("Starting InfluxDB container...");
let output = Command::new("docker")
.args([
"run", "-d",
"--name", "foxhunt-test-influxdb",
"-e", "DOCKER_INFLUXDB_INIT_MODE=setup",
"-e", "DOCKER_INFLUXDB_INIT_USERNAME=test",
"-e", "DOCKER_INFLUXDB_INIT_PASSWORD=test123456",
"-e", "DOCKER_INFLUXDB_INIT_ORG=foxhunt",
"-e", "DOCKER_INFLUXDB_INIT_BUCKET=test",
"-p", "8086:8086",
"--rm",
"influxdb:2.7-alpine"
])
.output()?;
if !output.status.success() {
return Err(anyhow::anyhow!(
"Failed to start InfluxDB container: {}",
String::from_utf8_lossy(&output.stderr)
));
}
let container_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
self.influxdb_container = Some(container_id);
println!("InfluxDB container started");
Ok(())
}
/// Setup Redis test cache
async fn setup_redis(&mut self) -> Result<()> {
println!("Starting Redis container...");
let output = Command::new("docker")
.args([
"run", "-d",
"--name", "foxhunt-test-redis",
"-p", "6379:6379",
"--rm",
"redis:7-alpine"
])
.output()?;
if !output.status.success() {
return Err(anyhow::anyhow!(
"Failed to start Redis container: {}",
String::from_utf8_lossy(&output.stderr)
));
}
let container_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
self.redis_container = Some(container_id);
println!("Redis container started");
Ok(())
}
/// Wait for all services to be ready
async fn wait_for_services(&self) -> Result<()> {
println!("Waiting for services to be ready...");
let timeout_duration = Duration::from_secs(60);
// Wait for PostgreSQL
timeout(timeout_duration, async {
loop {
if self.check_postgres_ready().await {
break;
}
tokio::time::sleep(Duration::from_millis(1000)).await;
}
}).await?;
// Wait for InfluxDB
timeout(timeout_duration, async {
loop {
if self.check_influxdb_ready().await {
break;
}
tokio::time::sleep(Duration::from_millis(1000)).await;
}
}).await?;
// Wait for Redis
timeout(timeout_duration, async {
loop {
if self.check_redis_ready().await {
break;
}
tokio::time::sleep(Duration::from_millis(1000)).await;
}
}).await?;
println!("All services are ready");
Ok(())
}
/// Check if PostgreSQL is ready
async fn check_postgres_ready(&self) -> bool {
let output = Command::new("docker")
.args([
"exec", "foxhunt-test-postgres",
"pg_isready", "-U", "test", "-d", "foxhunt_test"
])
.output();
matches!(output, Ok(output) if output.status.success())
}
/// Check if InfluxDB is ready
async fn check_influxdb_ready(&self) -> bool {
let output = Command::new("curl")
.args(["-f", "http://localhost:8086/health"])
.output();
matches!(output, Ok(output) if output.status.success())
}
/// Check if Redis is ready
async fn check_redis_ready(&self) -> bool {
let output = Command::new("docker")
.args([
"exec", "foxhunt-test-redis",
"redis-cli", "ping"
])
.output();
matches!(output, Ok(output) if output.status.success() &&
String::from_utf8_lossy(&output.stdout).trim() == "PONG")
}
/// Initialize database schemas
async fn initialize_schemas(&self) -> Result<()> {
println!("Initializing database schemas...");
// Create PostgreSQL tables
self.create_postgres_tables().await?;
// Create InfluxDB buckets and measurements
self.create_influxdb_schema().await?;
println!("Database schemas initialized");
Ok(())
}
/// Create PostgreSQL test tables
async fn create_postgres_tables(&self) -> Result<()> {
let sql_commands = vec![
// Trading tables
r#"
CREATE TABLE IF NOT EXISTS trades (
id BIGSERIAL PRIMARY KEY,
symbol VARCHAR(10) NOT NULL,
price DECIMAL(18,8) NOT NULL,
quantity DECIMAL(18,8) NOT NULL,
side VARCHAR(4) NOT NULL CHECK (side IN ('BUY', 'SELL')),
timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
order_id UUID,
model_id VARCHAR(100),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
"#,
// ML model registry
r#"
CREATE TABLE IF NOT EXISTS ml_models (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
model_name VARCHAR(100) NOT NULL,
model_type VARCHAR(50) NOT NULL,
version VARCHAR(20) NOT NULL,
symbol VARCHAR(10) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
performance_metrics JSONB,
hyperparameters JSONB,
model_path TEXT,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
UNIQUE(model_name, version, symbol)
);
"#,
// Training jobs
r#"
CREATE TABLE IF NOT EXISTS training_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
job_id VARCHAR(100) UNIQUE NOT NULL,
model_name VARCHAR(100) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'QUEUED',
progress_percentage DECIMAL(5,2) DEFAULT 0.0,
current_epoch INTEGER DEFAULT 0,
total_epochs INTEGER DEFAULT 0,
hyperparameters JSONB,
resource_requirements JSONB,
error_message TEXT,
started_at TIMESTAMP WITH TIME ZONE,
completed_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
"#,
// Market data
r#"
CREATE TABLE IF NOT EXISTS market_data (
id BIGSERIAL PRIMARY KEY,
symbol VARCHAR(10) NOT NULL,
timestamp TIMESTAMP WITH TIME ZONE NOT NULL,
price DECIMAL(18,8) NOT NULL,
volume BIGINT NOT NULL,
bid DECIMAL(18,8),
ask DECIMAL(18,8),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
"#,
// Create indexes
"CREATE INDEX IF NOT EXISTS idx_trades_symbol_timestamp ON trades(symbol, timestamp DESC);",
"CREATE INDEX IF NOT EXISTS idx_trades_model_id ON trades(model_id);",
"CREATE INDEX IF NOT EXISTS idx_ml_models_name_version ON ml_models(model_name, version);",
"CREATE INDEX IF NOT EXISTS idx_training_jobs_status ON training_jobs(status);",
"CREATE INDEX IF NOT EXISTS idx_market_data_symbol_timestamp ON market_data(symbol, timestamp DESC);",
];
for sql in sql_commands {
let output = Command::new("docker")
.args([
"exec", "foxhunt-test-postgres",
"psql", "-U", "test", "-d", "foxhunt_test",
"-c", sql
])
.output()?;
if !output.status.success() {
eprintln!("Failed to execute SQL: {}", sql);
eprintln!("Error: {}", String::from_utf8_lossy(&output.stderr));
return Err(anyhow::anyhow!("Failed to create PostgreSQL tables"));
}
}
Ok(())
}
/// Create InfluxDB schema
async fn create_influxdb_schema(&self) -> Result<()> {
// InfluxDB 2.x uses buckets instead of databases
// The bucket was already created during container initialization
// Here we can create retention policies or other schema elements if needed
Ok(())
}
/// Stop and remove a Docker container
async fn stop_container(&self, container_id: &str) -> Result<()> {
let output = Command::new("docker")
.args(["stop", container_id])
.output()?;
if !output.status.success() {
eprintln!("Warning: Failed to stop container {}: {}",
container_id, String::from_utf8_lossy(&output.stderr));
}
Ok(())
}
/// Insert test data into PostgreSQL
pub async fn insert_test_trades(&self, trades: &[TestTrade]) -> Result<()> {
for trade in trades {
let sql = format!(
"INSERT INTO trades (symbol, price, quantity, side, timestamp, model_id) VALUES ('{}', {}, {}, '{}', '{}', '{}')",
trade.symbol, trade.price, trade.quantity, trade.side, trade.timestamp, trade.model_id.as_deref().unwrap_or("NULL")
);
let output = Command::new("docker")
.args([
"exec", "foxhunt-test-postgres",
"psql", "-U", "test", "-d", "foxhunt_test",
"-c", &sql
])
.output()?;
if !output.status.success() {
return Err(anyhow::anyhow!(
"Failed to insert test trade: {}",
String::from_utf8_lossy(&output.stderr)
));
}
}
Ok(())
}
/// Insert test ML model records
pub async fn insert_test_models(&self, models: &[TestModel]) -> Result<()> {
for model in models {
let performance_json = serde_json::to_string(&model.performance_metrics)?;
let hyperparams_json = serde_json::to_string(&model.hyperparameters)?;
let sql = format!(
"INSERT INTO ml_models (model_name, model_type, version, symbol, status, performance_metrics, hyperparameters, model_path) VALUES ('{}', '{}', '{}', '{}', '{}', '{}', '{}', '{}')",
model.model_name, model.model_type, model.version, model.symbol, model.status,
performance_json, hyperparams_json, model.model_path.as_deref().unwrap_or("")
);
let output = Command::new("docker")
.args([
"exec", "foxhunt-test-postgres",
"psql", "-U", "test", "-d", "foxhunt_test",
"-c", &sql
])
.output()?;
if !output.status.success() {
return Err(anyhow::anyhow!(
"Failed to insert test model: {}",
String::from_utf8_lossy(&output.stderr)
));
}
}
Ok(())
}
/// Clean all test data from databases
pub async fn clean_test_data(&self) -> Result<()> {
let tables = vec!["trades", "ml_models", "training_jobs", "market_data"];
for table in tables {
let sql = format!("TRUNCATE TABLE {} RESTART IDENTITY CASCADE", table);
let output = Command::new("docker")
.args([
"exec", "foxhunt-test-postgres",
"psql", "-U", "test", "-d", "foxhunt_test",
"-c", &sql
])
.output()?;
if !output.status.success() {
eprintln!("Warning: Failed to clean table {}: {}",
table, String::from_utf8_lossy(&output.stderr));
}
}
Ok(())
}
}
/// Test trade data structure
#[derive(Debug, Clone)]
pub struct TestTrade {
pub symbol: String,
pub price: f64,
pub quantity: f64,
pub side: String,
pub timestamp: chrono::DateTime<chrono::Utc>,
pub model_id: Option<String>,
}
/// Test ML model data structure
#[derive(Debug, Clone)]
pub struct TestModel {
pub model_name: String,
pub model_type: String,
pub version: String,
pub symbol: String,
pub status: String,
pub performance_metrics: std::collections::HashMap<String, f64>,
pub hyperparameters: std::collections::HashMap<String, String>,
pub model_path: Option<String>,
}
impl Default for TestTrade {
fn default() -> Self {
Self {
symbol: "AAPL".to_string(),
price: 150.0,
quantity: 100.0,
side: "BUY".to_string(),
timestamp: chrono::Utc::now(),
model_id: Some("test_model_v1".to_string()),
}
}
}
impl Default for TestModel {
fn default() -> Self {
let mut performance_metrics = std::collections::HashMap::new();
performance_metrics.insert("accuracy".to_string(), 0.75);
performance_metrics.insert("sharpe_ratio".to_string(), 1.2);
let mut hyperparameters = std::collections::HashMap::new();
hyperparameters.insert("learning_rate".to_string(), "0.001".to_string());
hyperparameters.insert("batch_size".to_string(), "32".to_string());
Self {
model_name: "test_model".to_string(),
model_type: "DQN".to_string(),
version: "1.0.0".to_string(),
symbol: "AAPL".to_string(),
status: "ACTIVE".to_string(),
performance_metrics,
hyperparameters,
model_path: Some("/tmp/test_models/test_model.pkl".to_string()),
}
}
}