//! Per-test database isolation using PostgreSQL template databases. //! //! A shared template database is created once (with all migrations) and each //! test gets a cheap `CREATE DATABASE ... TEMPLATE` clone. Dropped //! automatically when `TestDb` goes out of scope. use sqlx::postgres::PgPoolOptions; use sqlx::{Connection, Executor, PgConnection, PgPool}; use std::sync::Once; use std::time::Duration; use uuid::Uuid; /// Name of the shared template database, created once per test suite run. const TEMPLATE_DB_NAME: &str = "mnw_test_template"; /// Ensures template creation runs exactly once, across all threads and runtimes. static TEMPLATE_INIT: Once = Once::new(); fn admin_url() -> String { std::env::var("TEST_DATABASE_URL") .unwrap_or_else(|_| "postgres://localhost/postgres".to_string()) } /// Create the template database with all migrations. Runs in a dedicated /// single-threaded tokio runtime so it works from any context (including /// inside `#[tokio::test]` and plain `#[test]`). fn ensure_template() { TEMPLATE_INIT.call_once(|| { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .expect("build template setup runtime"); rt.block_on(async { let t0 = std::time::Instant::now(); let admin = admin_url(); let mut conn = PgConnection::connect(&admin) .await .expect("connect to admin DB for template setup"); // Drop stale template if it exists (migrations may have changed) let _ = conn.execute(format!( "DROP DATABASE IF EXISTS \"{TEMPLATE_DB_NAME}\" WITH (FORCE)" ).as_str()).await; conn.execute(format!( "CREATE DATABASE \"{TEMPLATE_DB_NAME}\"" ).as_str()) .await .expect("create template database"); // Connect to the template and run all migrations let tpl_url = replace_db_name(&admin, TEMPLATE_DB_NAME); let tpl_pool = PgPoolOptions::new() .max_connections(2) .acquire_timeout(Duration::from_secs(10)) .connect(&tpl_url) .await .expect("connect to template database"); let t_migrate = std::time::Instant::now(); sqlx::migrate!("./migrations") .run(&tpl_pool) .await .expect("run migrations on template"); let migrate_ms = t_migrate.elapsed().as_millis(); // Also create the session store table let session_store = tower_sessions_sqlx_store::PostgresStore::new(tpl_pool.clone()); session_store.migrate().await.expect("session store migration on template"); tpl_pool.close().await; let total_ms = t0.elapsed().as_millis(); eprintln!( "[test-harness] Template DB created in {}ms (migrations: {}ms)", total_ms, migrate_ms ); }); }); } /// An isolated test database that cleans up after itself. pub struct TestDb { pub pool: PgPool, db_name: String, admin_url: String, #[allow(dead_code)] test_url: String, /// Whether the session store table already exists (from template). pub session_migrated: bool, } impl TestDb { /// Create a fresh database cloned from the shared template. pub async fn new() -> Self { // ensure_template uses std::sync::Once + its own runtime, safe from any context. // When called from an async context, we run it on a blocking thread to avoid // nesting runtimes. tokio::task::spawn_blocking(ensure_template) .await .expect("template setup panicked"); let t0 = std::time::Instant::now(); let admin = admin_url(); let db_name = format!("mnw_test_{}", Uuid::new_v4().simple()); let mut admin_conn = PgConnection::connect(&admin) .await .expect("Failed to connect to admin database"); admin_conn .execute( format!( "CREATE DATABASE \"{db_name}\" TEMPLATE \"{TEMPLATE_DB_NAME}\"" ) .as_str(), ) .await .expect("Failed to create test database from template"); let test_url = replace_db_name(&admin, &db_name); let pool = PgPoolOptions::new() .max_connections(5) .acquire_timeout(Duration::from_secs(5)) .connect(&test_url) .await .expect("Failed to connect to test database"); let clone_ms = t0.elapsed().as_millis(); if clone_ms > 500 { eprintln!( "[test-harness] SLOW DB clone: {}ms for {}", clone_ms, db_name ); } TestDb { pool, db_name, admin_url: admin, test_url, session_migrated: true, } } /// The connection URL for this test database. #[allow(dead_code)] pub fn url(&self) -> &str { &self.test_url } } impl Drop for TestDb { fn drop(&mut self) { let admin_url = self.admin_url.clone(); let db_name = self.db_name.clone(); self.pool.close_event(); std::thread::spawn(move || { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .expect("Failed to build cleanup runtime"); rt.block_on(async { if let Ok(mut conn) = PgConnection::connect(&admin_url).await { let _ = conn .execute( format!( "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{}'", db_name ) .as_str(), ) .await; let _ = conn .execute(format!("DROP DATABASE IF EXISTS \"{}\"", db_name).as_str()) .await; } }); }) .join() .ok(); } } /// Replace the database name in a PostgreSQL connection URL. fn replace_db_name(url: &str, new_db: &str) -> String { if let Some(pos) = url.rfind('/') { let base = &url[..pos]; let query = url[pos + 1..] .find('?') .map(|q| &url[pos + 1 + q..]) .unwrap_or(""); if query.is_empty() { format!("{}/{}", base, new_db) } else { format!("{}/{}{}", base, new_db, query) } } else { panic!("Invalid database URL: no '/' found"); } } #[cfg(test)] pub mod tests { use super::*; #[test] fn replace_db_name_simple() { let result = replace_db_name("postgres://localhost/postgres", "test_db"); assert_eq!(result, "postgres://localhost/test_db"); } #[test] fn replace_db_name_with_auth() { let result = replace_db_name("postgres://user:pass@localhost:5432/mydb", "test_db"); assert_eq!(result, "postgres://user:pass@localhost:5432/test_db"); } #[test] fn replace_db_name_with_query() { let result = replace_db_name( "postgres://localhost/postgres?sslmode=disable", "test_db", ); assert_eq!(result, "postgres://localhost/test_db?sslmode=disable"); } }