//! Test harness for real-Postgres integration tests. //! //! Every test gets its own freshly-migrated database on a shared server: //! either the one named by `CM_TEST_DATABASE_URL` (air-gapped CI runs a //! preloaded Postgres) or a `postgres:16-alpine` testcontainer started once //! per test process. There are no in-memory fakes; the suite proves behavior //! against the same engine production runs. use sqlx::postgres::PgPoolOptions; use sqlx::PgPool; use testcontainers_modules::postgres::Postgres; use testcontainers_modules::testcontainers::runners::AsyncRunner; use testcontainers_modules::testcontainers::ContainerAsync; use testcontainers_modules::testcontainers::ImageExt; use tokio::sync::OnceCell; use uuid::Uuid; static SERVER: OnceCell = OnceCell::const_new(); struct PgServer { /// Connection URL whose path component is a maintenance database we can /// issue `CREATE DATABASE` from. admin_url: String, /// Keeps the container alive for the whole test process; `None` when an /// external server is provided via `CM_TEST_DATABASE_URL`. _container: Option>, } async fn server() -> &'static PgServer { SERVER .get_or_init(|| async { if let Ok(url) = std::env::var("CM_TEST_DATABASE_URL") { // Only on the shared-server path. The testcontainer below is // torn down with the process, so it has nothing to reap and a // sweep there would be pure cost. reap_stale_databases(&url).await; return PgServer { admin_url: url, _container: None, }; } // Pin the tag: `Postgres::default()` resolves to the EOL // `postgres:11-alpine` in testcontainers-modules 0.13. Match prod // (and `scripts/test.sh`) on 16-alpine. The shared-server path // (`CM_TEST_DATABASE_URL`, above) is preferred and starts no // container at all — see scripts/test.sh. let container = Postgres::default() .with_tag("16-alpine") .start() .await .expect("start postgres testcontainer"); let port = container .get_host_port_ipv4(5432) .await .expect("resolve postgres port"); PgServer { admin_url: format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"), _container: Some(container), } }) .await } /// How long a test database may sit before another test process reaps it. /// /// Comfortably longer than any test run, so a database in use by a /// concurrently-running binary is never a candidate. Nothing here needs to be /// prompt — the point is that the set stays bounded, not that it stays empty. const STALE_AFTER_MS: u64 = 2 * 60 * 60 * 1000; /// Drop test databases left behind by earlier runs. /// /// `test_pool` creates a database per test and nothing ever dropped it. On the /// testcontainer path that is invisible: the container dies with the process /// and takes them with it. But `CM_TEST_DATABASE_URL` points at a SHARED /// server that outlives the run — which is the path CI uses and the path /// `.cargo/config.toml` sets for local development — so on both of those every /// database ever created is still there. /// /// Measured before writing this: **3,546 databases, 38 GB** on one developer /// machine. It grows with every `cargo test`. /// /// Age comes from the name, not the catalogue. Postgres records no creation /// time for a database, but the names are `test_` and UUIDv7 puts the /// millisecond timestamp in its first 48 bits — the same property /// `mission_runtime::container_name` relies on. /// /// Best-effort throughout: a test must never fail because housekeeping could /// not run. async fn reap_stale_databases(admin_url: &str) { let Ok(admin) = PgPoolOptions::new() .max_connections(1) .connect(admin_url) .await else { return; }; let names: Vec = sqlx::query_scalar( "SELECT datname FROM pg_database WHERE datname LIKE 'test\\_%'", ) .fetch_all(&admin) .await .unwrap_or_default(); let now_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0); let mut dropped = 0usize; for name in names { let Some(created) = uuid_v7_millis(&name) else { // Not a name we minted; leave it entirely alone. continue; }; if now_ms.saturating_sub(created) < STALE_AFTER_MS { continue; } // FORCE terminates any leftover connection; without it a single stale // session pins the database and the reap silently does nothing. if sqlx::query(&format!("DROP DATABASE IF EXISTS {name} WITH (FORCE)")) .execute(&admin) .await .is_ok() { dropped += 1; } } if dropped > 0 { eprintln!("cm-testkit: reaped {dropped} stale test database(s)"); } admin.close().await; } /// The millisecond timestamp encoded in the leading 48 bits of a /// `test_` name. fn uuid_v7_millis(db_name: &str) -> Option { let hex = db_name.strip_prefix("test_")?; if hex.len() != 32 || !hex.chars().all(|c| c.is_ascii_hexdigit()) { return None; } u64::from_str_radix(&hex[..12], 16).ok() } /// Creates a unique database, runs all migrations, and returns a pool /// connected to it. pub async fn test_pool() -> PgPool { let server = server().await; let db_name = format!("test_{}", Uuid::now_v7().simple()); let admin = PgPoolOptions::new() .max_connections(1) .connect(&server.admin_url) .await .expect("connect admin database"); sqlx::query(&format!("CREATE DATABASE {db_name}")) .execute(&admin) .await .expect("create test database"); admin.close().await; let test_url = swap_database(&server.admin_url, &db_name); let pool = PgPoolOptions::new() .max_connections(20) .connect(&test_url) .await .expect("connect test database"); cm_db::MIGRATOR.run(&pool).await.expect("run migrations"); pool } /// Replaces the database name (the path segment) of a Postgres URL. fn swap_database(url: &str, db_name: &str) -> String { let (head, tail) = url.rsplit_once('/').expect("postgres url has a path"); // Preserve any query string on the original URL. match tail.split_once('?') { Some((_, query)) => format!("{head}/{db_name}?{query}"), None => format!("{head}/{db_name}"), } } #[cfg(test)] mod tests { use super::{uuid_v7_millis, STALE_AFTER_MS}; /// Age comes from the NAME, because Postgres records no creation time for /// a database. UUIDv7 puts the millisecond timestamp in its first 48 bits. #[test] fn a_test_database_name_carries_its_own_age() { let id = uuid::Uuid::now_v7(); let name = format!("test_{}", id.simple()); let ms = uuid_v7_millis(&name).expect("a name we minted parses"); let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_millis() as u64; assert!( now.saturating_sub(ms) < 5_000, "a database created just now must read as new, or the reaper drops \ one another test process is still using" ); } /// Anything we did not mint is left alone. #[test] fn only_our_own_names_are_reapable() { assert!(uuid_v7_millis("clawmates").is_none()); assert!(uuid_v7_millis("postgres").is_none()); assert!(uuid_v7_millis("template1").is_none()); // Right prefix, wrong shape — a human-made `test_scratch` survives. assert!(uuid_v7_millis("test_scratch").is_none()); assert!(uuid_v7_millis("test_").is_none()); // Right length, not hex. assert!(uuid_v7_millis(&format!("test_{}", "z".repeat(32))).is_none()); } /// The window has to be longer than a test run, or the reaper deletes a /// database out from under a binary running in parallel. #[test] fn the_stale_window_outlasts_any_test_run() { assert!(STALE_AFTER_MS >= 60 * 60 * 1000); } }