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>
564 lines
16 KiB
Rust
564 lines
16 KiB
Rust
//! Comprehensive Migration Tests for Database Schema Evolution
|
|
//!
|
|
//! Tests cover:
|
|
//! - Migration metadata verification
|
|
//! - Sequential migration ordering
|
|
//! - Schema evolution (columns, indexes, constraints)
|
|
//! - Foreign key and check constraints
|
|
//! - Index creation and verification
|
|
//! - Extension and custom type verification
|
|
//! - Complete schema validation
|
|
//! - Data preservation verification
|
|
//!
|
|
//! Test Database Setup:
|
|
//! ```bash
|
|
//! docker-compose up -d postgres
|
|
//! cargo sqlx migrate run
|
|
//! ```
|
|
//!
|
|
//! Note: Tests use the existing foxhunt database with applied migrations.
|
|
|
|
use sqlx::postgres::PgPoolOptions;
|
|
use sqlx::{PgPool, Row};
|
|
use std::time::Instant;
|
|
use uuid::Uuid;
|
|
|
|
const DATABASE_URL: &str = "postgresql://foxhunt:foxhunt_dev_password@localhost:5432/foxhunt";
|
|
|
|
/// Helper to get database pool
|
|
async fn get_pool() -> Result<PgPool, sqlx::Error> {
|
|
PgPoolOptions::new()
|
|
.max_connections(5)
|
|
.connect(DATABASE_URL)
|
|
.await
|
|
}
|
|
|
|
/// Helper to count applied migrations
|
|
async fn count_migrations(pool: &PgPool) -> Result<i64, sqlx::Error> {
|
|
let row = sqlx::query("SELECT COUNT(*) as count FROM _sqlx_migrations")
|
|
.fetch_one(pool)
|
|
.await?;
|
|
Ok(row.get("count"))
|
|
}
|
|
|
|
/// Helper to get migration versions
|
|
async fn get_migration_versions(pool: &PgPool) -> Result<Vec<i64>, sqlx::Error> {
|
|
let rows = sqlx::query("SELECT version FROM _sqlx_migrations ORDER BY version")
|
|
.fetch_all(pool)
|
|
.await?;
|
|
Ok(rows.iter().map(|r| r.get("version")).collect())
|
|
}
|
|
|
|
/// Helper to check if table exists
|
|
async fn table_exists(pool: &PgPool, table_name: &str) -> Result<bool, sqlx::Error> {
|
|
let row = sqlx::query(
|
|
"SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = $1) as exists",
|
|
)
|
|
.bind(table_name)
|
|
.fetch_one(pool)
|
|
.await?;
|
|
Ok(row.get("exists"))
|
|
}
|
|
|
|
/// Helper to get table columns
|
|
async fn get_columns(pool: &PgPool, table_name: &str) -> Result<Vec<String>, sqlx::Error> {
|
|
let rows = sqlx::query(
|
|
"SELECT column_name FROM information_schema.columns
|
|
WHERE table_name = $1 ORDER BY ordinal_position",
|
|
)
|
|
.bind(table_name)
|
|
.fetch_all(pool)
|
|
.await?;
|
|
Ok(rows.iter().map(|r| r.get("column_name")).collect())
|
|
}
|
|
|
|
/// Helper to check if index exists
|
|
async fn index_exists(pool: &PgPool, index_name: &str) -> Result<bool, sqlx::Error> {
|
|
let row =
|
|
sqlx::query("SELECT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = $1) as exists")
|
|
.bind(index_name)
|
|
.fetch_one(pool)
|
|
.await?;
|
|
Ok(row.get("exists"))
|
|
}
|
|
|
|
// ============================================================================
|
|
// Migration Metadata Tests
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_migrations_table_exists() {
|
|
let pool = get_pool().await.expect("Failed to connect");
|
|
assert!(table_exists(&pool, "_sqlx_migrations").await.unwrap());
|
|
println!("✅ Migration tracking table exists");
|
|
pool.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_migrations_applied() {
|
|
let pool = get_pool().await.expect("Failed to connect");
|
|
let count = count_migrations(&pool).await.unwrap();
|
|
assert!(count > 0, "Expected migrations, got {}", count);
|
|
println!("✅ {} migrations applied", count);
|
|
pool.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_migration_sequential_ordering() {
|
|
let pool = get_pool().await.expect("Failed to connect");
|
|
let versions = get_migration_versions(&pool).await.unwrap();
|
|
|
|
for i in 1..versions.len() {
|
|
assert!(
|
|
versions[i] > versions[i - 1],
|
|
"Migrations not sequential: {} > {}",
|
|
versions[i],
|
|
versions[i - 1]
|
|
);
|
|
}
|
|
println!("✅ {} migrations in sequential order", versions.len());
|
|
pool.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_all_migrations_succeeded() {
|
|
let pool = get_pool().await.expect("Failed to connect");
|
|
let failed: i64 =
|
|
sqlx::query_scalar("SELECT COUNT(*) FROM _sqlx_migrations WHERE success = false")
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(failed, 0, "Found {} failed migrations", failed);
|
|
println!("✅ All migrations succeeded");
|
|
pool.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_migration_timestamps() {
|
|
let pool = get_pool().await.expect("Failed to connect");
|
|
let rows = sqlx::query("SELECT version, installed_on FROM _sqlx_migrations ORDER BY version")
|
|
.fetch_all(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(!rows.is_empty(), "No migrations found");
|
|
println!("✅ {} migrations have timestamps", rows.len());
|
|
pool.close().await;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Schema Verification Tests
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_trading_events_table() {
|
|
let pool = get_pool().await.expect("Failed to connect");
|
|
assert!(table_exists(&pool, "trading_events").await.unwrap());
|
|
|
|
let cols = get_columns(&pool, "trading_events").await.unwrap();
|
|
assert!(cols.contains(&"id".to_string()));
|
|
assert!(cols.contains(&"event_type".to_string()));
|
|
assert!(cols.contains(&"event_timestamp".to_string()));
|
|
|
|
println!("✅ trading_events table verified: {} columns", cols.len());
|
|
pool.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_risk_events_table() {
|
|
let pool = get_pool().await.expect("Failed to connect");
|
|
assert!(table_exists(&pool, "risk_events").await.unwrap());
|
|
|
|
let cols = get_columns(&pool, "risk_events").await.unwrap();
|
|
assert!(cols.contains(&"id".to_string()));
|
|
assert!(cols.contains(&"event_type".to_string()));
|
|
|
|
println!("✅ risk_events table verified: {} columns", cols.len());
|
|
pool.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_audit_log_table() {
|
|
let pool = get_pool().await.expect("Failed to connect");
|
|
assert!(table_exists(&pool, "audit_log").await.unwrap());
|
|
println!("✅ audit_log table verified");
|
|
pool.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_users_table() {
|
|
let pool = get_pool().await.expect("Failed to connect");
|
|
assert!(table_exists(&pool, "users").await.unwrap());
|
|
|
|
let cols = get_columns(&pool, "users").await.unwrap();
|
|
assert!(cols.contains(&"id".to_string()));
|
|
assert!(cols.contains(&"username".to_string()));
|
|
assert!(cols.contains(&"email".to_string()));
|
|
assert!(cols.contains(&"password_hash".to_string()));
|
|
|
|
println!("✅ users table verified: {} columns", cols.len());
|
|
pool.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_executions_table() {
|
|
let pool = get_pool().await.expect("Failed to connect");
|
|
assert!(table_exists(&pool, "executions").await.unwrap());
|
|
|
|
let cols = get_columns(&pool, "executions").await.unwrap();
|
|
assert!(cols.contains(&"id".to_string()));
|
|
assert!(cols.contains(&"order_id".to_string()));
|
|
assert!(cols.contains(&"account_id".to_string()));
|
|
assert!(cols.contains(&"symbol".to_string()));
|
|
|
|
println!("✅ executions table verified: {} columns", cols.len());
|
|
pool.close().await;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Index Verification Tests
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_executions_indexes() {
|
|
let pool = get_pool().await.expect("Failed to connect");
|
|
|
|
assert!(index_exists(&pool, "idx_executions_account_id")
|
|
.await
|
|
.unwrap());
|
|
assert!(index_exists(&pool, "idx_executions_order_id")
|
|
.await
|
|
.unwrap());
|
|
|
|
println!("✅ Executions table indexes verified");
|
|
pool.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_index_count() {
|
|
let pool = get_pool().await.expect("Failed to connect");
|
|
|
|
let count: i64 =
|
|
sqlx::query_scalar("SELECT COUNT(*) FROM pg_indexes WHERE schemaname = 'public'")
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(count >= 5, "Expected at least 5 indexes, got {}", count);
|
|
println!("✅ {} indexes found", count);
|
|
pool.close().await;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Constraint Tests
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_check_constraint_negative_quantity() {
|
|
let pool = get_pool().await.expect("Failed to connect");
|
|
|
|
if !table_exists(&pool, "executions").await.unwrap() {
|
|
println!("⚠️ Skipping: executions table doesn't exist");
|
|
return;
|
|
}
|
|
|
|
let result = sqlx::query(
|
|
"INSERT INTO executions (order_id, account_id, symbol, side, quantity, price)
|
|
VALUES ($1, $2, $3, $4, $5, $6)",
|
|
)
|
|
.bind(Uuid::new_v4())
|
|
.bind("test")
|
|
.bind("TEST")
|
|
.bind("buy")
|
|
.bind(-100_i64)
|
|
.bind(1000_i64)
|
|
.execute(&pool)
|
|
.await;
|
|
|
|
assert!(
|
|
result.is_err(),
|
|
"Check constraint should reject negative quantity"
|
|
);
|
|
println!("✅ Check constraint verified");
|
|
pool.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_unique_constraint_username() {
|
|
let pool = get_pool().await.expect("Failed to connect");
|
|
|
|
let username = format!("test_user_{}", Uuid::new_v4().simple());
|
|
|
|
// First insert should succeed
|
|
let result1 = sqlx::query(
|
|
"INSERT INTO users (username, email, password_hash, salt)
|
|
VALUES ($1, $2, $3, $4)",
|
|
)
|
|
.bind(&username)
|
|
.bind(format!("{}@test.com", username))
|
|
.bind("hash")
|
|
.bind("salt")
|
|
.execute(&pool)
|
|
.await;
|
|
|
|
if result1.is_ok() {
|
|
// Second insert should fail
|
|
let result2 = sqlx::query(
|
|
"INSERT INTO users (username, email, password_hash, salt)
|
|
VALUES ($1, $2, $3, $4)",
|
|
)
|
|
.bind(&username)
|
|
.bind(format!("{}2@test.com", username))
|
|
.bind("hash")
|
|
.bind("salt")
|
|
.execute(&pool)
|
|
.await;
|
|
|
|
assert!(
|
|
result2.is_err(),
|
|
"Unique constraint should reject duplicate"
|
|
);
|
|
println!("✅ Unique constraint verified");
|
|
|
|
// Cleanup
|
|
sqlx::query("DELETE FROM users WHERE username = $1")
|
|
.bind(&username)
|
|
.execute(&pool)
|
|
.await
|
|
.ok();
|
|
} else {
|
|
println!("⚠️ Skipping: users table not writable");
|
|
}
|
|
|
|
pool.close().await;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Extension Tests
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_uuid_extension() {
|
|
let pool = get_pool().await.expect("Failed to connect");
|
|
|
|
let extensions: Vec<String> =
|
|
sqlx::query_scalar("SELECT extname FROM pg_extension WHERE extname = 'uuid-ossp'")
|
|
.fetch_all(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(
|
|
!extensions.is_empty(),
|
|
"uuid-ossp extension should be installed"
|
|
);
|
|
println!("✅ uuid-ossp extension installed");
|
|
pool.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_custom_types() {
|
|
let pool = get_pool().await.expect("Failed to connect");
|
|
|
|
let types: Vec<String> = sqlx::query_scalar(
|
|
"SELECT typname FROM pg_type
|
|
WHERE typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public')
|
|
AND typtype = 'e'",
|
|
)
|
|
.fetch_all(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(!types.is_empty(), "Should have custom enum types");
|
|
println!("✅ {} custom types found", types.len());
|
|
pool.close().await;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Data Tests
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_insert_and_query_execution() {
|
|
let pool = get_pool().await.expect("Failed to connect");
|
|
|
|
if !table_exists(&pool, "executions").await.unwrap() {
|
|
println!("⚠️ Skipping: executions table doesn't exist");
|
|
return;
|
|
}
|
|
|
|
let test_id = Uuid::new_v4();
|
|
|
|
// Insert test record
|
|
let result = sqlx::query(
|
|
"INSERT INTO executions (id, order_id, account_id, symbol, side, quantity, price)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)",
|
|
)
|
|
.bind(test_id)
|
|
.bind(Uuid::new_v4())
|
|
.bind("test_account")
|
|
.bind("TESTBTC")
|
|
.bind("buy")
|
|
.bind(100_i64)
|
|
.bind(50000_i64)
|
|
.execute(&pool)
|
|
.await;
|
|
|
|
if result.is_ok() {
|
|
// Query it back
|
|
let found: Option<(Uuid,)> = sqlx::query_as("SELECT id FROM executions WHERE id = $1")
|
|
.bind(test_id)
|
|
.fetch_optional(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(found.is_some(), "Should find inserted record");
|
|
println!("✅ Data insertion and retrieval verified");
|
|
|
|
// Cleanup
|
|
sqlx::query("DELETE FROM executions WHERE id = $1")
|
|
.bind(test_id)
|
|
.execute(&pool)
|
|
.await
|
|
.ok();
|
|
} else {
|
|
println!("⚠️ Skipping: executions table not writable");
|
|
}
|
|
|
|
pool.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_bulk_insert_performance() {
|
|
let pool = get_pool().await.expect("Failed to connect");
|
|
|
|
if !table_exists(&pool, "executions").await.unwrap() {
|
|
println!("⚠️ Skipping: executions table doesn't exist");
|
|
return;
|
|
}
|
|
|
|
let start = Instant::now();
|
|
let batch_size = 100;
|
|
let test_symbol = format!(
|
|
"TEST{}",
|
|
Uuid::new_v4()
|
|
.simple()
|
|
.to_string()
|
|
.chars()
|
|
.take(6)
|
|
.collect::<String>()
|
|
);
|
|
|
|
for _ in 0..batch_size {
|
|
let result = sqlx::query(
|
|
"INSERT INTO executions (order_id, account_id, symbol, side, quantity, price)
|
|
VALUES ($1, $2, $3, $4, $5, $6)",
|
|
)
|
|
.bind(Uuid::new_v4())
|
|
.bind("test_account")
|
|
.bind(&test_symbol)
|
|
.bind("buy")
|
|
.bind(100_i64)
|
|
.bind(50000_i64)
|
|
.execute(&pool)
|
|
.await;
|
|
|
|
if result.is_err() {
|
|
println!("⚠️ Skipping: executions table not writable");
|
|
pool.close().await;
|
|
return;
|
|
}
|
|
}
|
|
|
|
let duration = start.elapsed();
|
|
let throughput = batch_size as f64 / duration.as_secs_f64();
|
|
|
|
println!(
|
|
"✅ Bulk insert: {} rows in {:?} ({:.0} rows/sec)",
|
|
batch_size, duration, throughput
|
|
);
|
|
|
|
// Cleanup
|
|
sqlx::query("DELETE FROM executions WHERE symbol = $1")
|
|
.bind(&test_symbol)
|
|
.execute(&pool)
|
|
.await
|
|
.ok();
|
|
|
|
pool.close().await;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Schema Coverage Tests
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_all_tables() {
|
|
let pool = get_pool().await.expect("Failed to connect");
|
|
|
|
let tables: Vec<String> = sqlx::query_scalar(
|
|
"SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename",
|
|
)
|
|
.fetch_all(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(!tables.is_empty(), "Should have tables");
|
|
println!("✅ {} tables found:", tables.len());
|
|
for table in tables.iter().take(20) {
|
|
println!(" - {}", table);
|
|
}
|
|
|
|
pool.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_all_views() {
|
|
let pool = get_pool().await.expect("Failed to connect");
|
|
|
|
let views: Vec<String> = sqlx::query_scalar(
|
|
"SELECT viewname FROM pg_views WHERE schemaname = 'public' ORDER BY viewname",
|
|
)
|
|
.fetch_all(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
println!("✅ {} views found", views.len());
|
|
pool.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_all_functions() {
|
|
let pool = get_pool().await.expect("Failed to connect");
|
|
|
|
let functions: Vec<String> = sqlx::query_scalar(
|
|
"SELECT proname FROM pg_proc
|
|
WHERE pronamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public')
|
|
ORDER BY proname",
|
|
)
|
|
.fetch_all(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
println!("✅ {} functions found", functions.len());
|
|
pool.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_migration_file_count() {
|
|
let migration_files = std::fs::read_dir("./migrations")
|
|
.expect("Failed to read migrations directory")
|
|
.filter_map(|e| e.ok())
|
|
.filter(|e| {
|
|
let name = e.file_name();
|
|
let name_str = name.to_str().unwrap();
|
|
e.path().extension().and_then(|s| s.to_str()) == Some("sql")
|
|
&& !name_str.contains("backup")
|
|
&& !name_str.contains("broken")
|
|
})
|
|
.count();
|
|
|
|
assert!(migration_files > 0, "Should have migration files");
|
|
println!("✅ {} migration files found", migration_files);
|
|
}
|