use std::path::PathBuf;
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use tempfile::TempDir;
use uuid::Uuid;
use soma_schema::error::Error;
use soma_schema::{Migrator, PostgresConfig, PostgresDriver};
fn test_db_url() -> String {
std::env::var("TEST_DATABASE_URL")
.expect("TEST_DATABASE_URL must be set to run integration tests")
}
struct SchemaGuard {
pool: PgPool,
pub schema: String,
}
impl SchemaGuard {
async fn cleanup(&self) {
let _ = sqlx::query(&format!(
"DROP SCHEMA IF EXISTS \"{}\" CASCADE",
self.schema
))
.execute(&self.pool)
.await;
}
}
impl Drop for SchemaGuard {
fn drop(&mut self) {
let pool = self.pool.clone();
let schema = self.schema.clone();
std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("build cleanup runtime");
rt.block_on(async {
let _ = sqlx::query(&format!("DROP SCHEMA IF EXISTS \"{schema}\" CASCADE"))
.execute(&pool)
.await;
});
})
.join()
.ok();
}
}
async fn make_schema(pool: &PgPool) -> SchemaGuard {
let schema = format!("_sdm_test_{}", Uuid::new_v4().simple());
sqlx::query(&format!("CREATE SCHEMA \"{schema}\""))
.execute(pool)
.await
.expect("create test schema");
SchemaGuard {
pool: pool.clone(),
schema,
}
}
async fn make_pool() -> PgPool {
PgPoolOptions::new()
.max_connections(5)
.connect(&test_db_url())
.await
.expect("connect to test database")
}
fn pg_config(schema: &str) -> PostgresConfig {
PostgresConfig {
schema: Some(schema.to_owned()),
table: "00_schema_migrations".to_owned(),
advisory_lock_key: 918273645,
}
}
struct MigrationsFixture {
_dir: TempDir,
root: PathBuf,
}
impl MigrationsFixture {
fn build(
setup_sql: Option<&str>,
versions: &[(u32, Vec<(&str, &str)>)],
yaml_override: Option<&str>,
) -> Self {
let dir = TempDir::new().unwrap();
let root = dir.path().to_path_buf();
if let Some(sql) = setup_sql {
std::fs::create_dir_all(root.join("00_setup")).unwrap();
std::fs::write(root.join("00_setup").join("01_schema.sql"), sql).unwrap();
}
for (v, files) in versions {
let migrated = root.join("01_migrated").join(v.to_string());
std::fs::create_dir_all(&migrated).unwrap();
for (fname, content) in files {
std::fs::write(migrated.join(fname), content).unwrap();
}
}
let yaml = match yaml_override {
Some(y) => y.to_owned(),
None => {
let mut out = String::from("manifest_version: 1\nversions:\n");
for (v, files) in versions {
out.push_str(&format!(" - version: {v}\n migrations:\n"));
for (fname, _) in files {
out.push_str(&format!(" - file: \"{fname}\"\n"));
}
}
out
}
};
std::fs::write(root.join("migration-order.yaml"), yaml).unwrap();
MigrationsFixture { _dir: dir, root }
}
}
fn migration_create_table(table: &str) -> String {
format!(
"CREATE TABLE {{SCHEMA}}.{table} (id SERIAL PRIMARY KEY);\n-- DOWN ==\nDROP TABLE IF EXISTS {{SCHEMA}}.{table};"
)
}
fn with_schema(sql: &str, schema: &str) -> String {
sql.replace("{SCHEMA}", schema)
}
#[tokio::test]
async fn test_fresh_up_applies_all_and_creates_tracking_rows() {
let pool = make_pool().await;
let guard = make_schema(&pool).await;
let schema = guard.schema.clone();
let setup_sql = "-- idempotent\nSELECT 1;";
let f = MigrationsFixture::build(
Some(setup_sql),
&[(
1,
vec![
(
"20260101_01_init.sql",
&with_schema(&migration_create_table("tbl_a"), &schema),
),
(
"20260101_02_more.sql",
&with_schema(&migration_create_table("tbl_b"), &schema),
),
],
)],
None,
);
let driver = PostgresDriver::new(pool.clone(), pg_config(&schema)).unwrap();
let migrator = Migrator::from_root(&f.root);
migrator.up(&driver).await.expect("up should succeed");
for tbl in ["tbl_a", "tbl_b"] {
let exists: (bool,) = sqlx::query_as(
"SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = $1 AND table_name = $2)"
)
.bind(&schema)
.bind(tbl)
.fetch_one(&pool)
.await
.unwrap();
assert!(exists.0, "table {tbl} should exist after up()");
}
let rows: Vec<(i32, String)> = sqlx::query_as(&format!(
"SELECT version, file FROM \"{schema}\".\"00_schema_migrations\" ORDER BY version, file"
))
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].1, "20260101_01_init.sql");
assert_eq!(rows[1].1, "20260101_02_more.sql");
guard.cleanup().await;
}
#[tokio::test]
async fn test_up_again_is_noop() {
let pool = make_pool().await;
let guard = make_schema(&pool).await;
let schema = guard.schema.clone();
let setup_sql = "SELECT 1;";
let f = MigrationsFixture::build(
Some(setup_sql),
&[(
1,
vec![(
"20260101_01_init.sql",
&with_schema(&migration_create_table("tbl_noop"), &schema),
)],
)],
None,
);
let driver = PostgresDriver::new(pool.clone(), pg_config(&schema)).unwrap();
let migrator = Migrator::from_root(&f.root);
migrator.up(&driver).await.unwrap();
let before: (i64,) = sqlx::query_as(&format!(
"SELECT COUNT(*) FROM \"{schema}\".\"00_schema_migrations\""
))
.fetch_one(&pool)
.await
.unwrap();
migrator
.up(&driver)
.await
.expect("second up() should be a no-op");
let after: (i64,) = sqlx::query_as(&format!(
"SELECT COUNT(*) FROM \"{schema}\".\"00_schema_migrations\""
))
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(before.0, after.0, "no new rows after second up()");
guard.cleanup().await;
}
#[tokio::test]
async fn test_checksum_drift_error() {
let pool = make_pool().await;
let guard = make_schema(&pool).await;
let schema = guard.schema.clone();
let original_sql = with_schema(&migration_create_table("tbl_drift"), &schema);
let f = MigrationsFixture::build(
Some("SELECT 1;"),
&[(1, vec![("20260101_01_init.sql", &original_sql)])],
None,
);
let driver = PostgresDriver::new(pool.clone(), pg_config(&schema)).unwrap();
let migrator = Migrator::from_root(&f.root);
migrator.up(&driver).await.unwrap();
std::fs::write(
f.root
.join("01_migrated")
.join("1")
.join("20260101_01_init.sql"),
format!("{original_sql}\n-- drift"),
)
.unwrap();
let result = migrator.up(&driver).await;
assert!(
matches!(result, Err(Error::ChecksumDrift { .. })),
"expected ChecksumDrift, got: {result:?}"
);
guard.cleanup().await;
}
#[tokio::test]
async fn test_down_one_step_reverts_newest() {
let pool = make_pool().await;
let guard = make_schema(&pool).await;
let schema = guard.schema.clone();
let f = MigrationsFixture::build(
Some("SELECT 1;"),
&[(
1,
vec![
(
"20260101_01_init.sql",
&with_schema(&migration_create_table("tbl_first"), &schema),
),
(
"20260101_02_more.sql",
&with_schema(&migration_create_table("tbl_second"), &schema),
),
],
)],
None,
);
let driver = PostgresDriver::new(pool.clone(), pg_config(&schema)).unwrap();
let migrator = Migrator::from_root(&f.root);
migrator.up(&driver).await.unwrap();
migrator
.down(&driver, 1)
.await
.expect("down 1 should succeed");
let second_exists: (bool,) = sqlx::query_as(
"SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = $1 AND table_name = $2)"
)
.bind(&schema)
.bind("tbl_second")
.fetch_one(&pool)
.await
.unwrap();
assert!(
!second_exists.0,
"tbl_second should be dropped after down 1"
);
let first_exists: (bool,) = sqlx::query_as(
"SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = $1 AND table_name = $2)"
)
.bind(&schema)
.bind("tbl_first")
.fetch_one(&pool)
.await
.unwrap();
assert!(first_exists.0, "tbl_first should still exist");
let rows: Vec<(String,)> = sqlx::query_as(&format!(
"SELECT file FROM \"{schema}\".\"00_schema_migrations\""
))
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].0, "20260101_01_init.sql");
guard.cleanup().await;
}
#[tokio::test]
async fn test_failing_migration_rolls_back_and_stops() {
let pool = make_pool().await;
let guard = make_schema(&pool).await;
let schema = guard.schema.clone();
let good_sql = with_schema(&migration_create_table("tbl_good"), &schema);
let bad_sql = "THIS IS NOT VALID SQL;";
let later_sql = with_schema(&migration_create_table("tbl_later"), &schema);
let f = MigrationsFixture::build(
Some("SELECT 1;"),
&[(
1,
vec![
("20260101_01_good.sql", &good_sql),
("20260101_02_bad.sql", bad_sql),
("20260101_03_later.sql", &later_sql),
],
)],
None,
);
let driver = PostgresDriver::new(pool.clone(), pg_config(&schema)).unwrap();
let migrator = Migrator::from_root(&f.root);
let result = migrator.up(&driver).await;
assert!(result.is_err(), "up should fail due to bad SQL");
let good_exists: (bool,) = sqlx::query_as(
"SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = $1 AND table_name = $2)"
)
.bind(&schema)
.bind("tbl_good")
.fetch_one(&pool)
.await
.unwrap();
assert!(
good_exists.0,
"tbl_good should exist (applied before the failure)"
);
let later_exists: (bool,) = sqlx::query_as(
"SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = $1 AND table_name = $2)"
)
.bind(&schema)
.bind("tbl_later")
.fetch_one(&pool)
.await
.unwrap();
assert!(
!later_exists.0,
"tbl_later should NOT exist (after the failure, not applied)"
);
let rows: Vec<(String,)> = sqlx::query_as(&format!(
"SELECT file FROM \"{schema}\".\"00_schema_migrations\""
))
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].0, "20260101_01_good.sql");
guard.cleanup().await;
}
#[tokio::test]
async fn test_manifest_order_honored_regardless_of_filename_sort() {
let pool = make_pool().await;
let guard = make_schema(&pool).await;
let schema = guard.schema.clone();
let sql_a = format!(
"CREATE TABLE \"{schema}\".order_a (id SERIAL PRIMARY KEY);\n-- DOWN ==\nDROP TABLE IF EXISTS \"{schema}\".order_a;"
);
let sql_b = format!(
"INSERT INTO \"{schema}\".order_a DEFAULT VALUES;\n-- DOWN ==\nDELETE FROM \"{schema}\".order_a;"
);
let yaml = r#"manifest_version: 1
versions:
- version: 1
migrations:
- file: "a_second.sql"
- file: "b_first.sql"
"#;
let dir = TempDir::new().unwrap();
let root = dir.path().to_path_buf();
std::fs::create_dir_all(root.join("01_migrated").join("1")).unwrap();
std::fs::write(
root.join("01_migrated").join("1").join("a_second.sql"),
&sql_a,
)
.unwrap();
std::fs::write(
root.join("01_migrated").join("1").join("b_first.sql"),
&sql_b,
)
.unwrap();
std::fs::write(root.join("migration-order.yaml"), yaml).unwrap();
let driver = PostgresDriver::new(pool.clone(), pg_config(&schema)).unwrap();
let migrator = Migrator::from_root(&root);
migrator
.up(&driver)
.await
.expect("manifest order should be honored (a before b)");
guard.cleanup().await;
}
#[tokio::test]
async fn test_version_folders_ordered_numerically() {
let pool = make_pool().await;
let guard = make_schema(&pool).await;
let schema = guard.schema.clone();
let sql_v1 = format!(
"CREATE TABLE \"{schema}\".v1_tbl (id SERIAL PRIMARY KEY);\n-- DOWN ==\nDROP TABLE IF EXISTS \"{schema}\".v1_tbl;"
);
let sql_v2 = format!(
"INSERT INTO \"{schema}\".v1_tbl DEFAULT VALUES;\n-- DOWN ==\nDELETE FROM \"{schema}\".v1_tbl;"
);
let sql_v10 =
format!("UPDATE \"{schema}\".v1_tbl SET id = id WHERE id > 0;\n-- DOWN ==\nSELECT 1;");
let yaml = r#"manifest_version: 1
versions:
- version: 1
migrations:
- file: "v1.sql"
- version: 2
migrations:
- file: "v2.sql"
- version: 10
migrations:
- file: "v10.sql"
"#;
let dir = TempDir::new().unwrap();
let root = dir.path().to_path_buf();
for (v, fname, content) in [
(1u32, "v1.sql", sql_v1.as_str()),
(2, "v2.sql", sql_v2.as_str()),
(10, "v10.sql", sql_v10.as_str()),
] {
std::fs::create_dir_all(root.join("01_migrated").join(v.to_string())).unwrap();
std::fs::write(
root.join("01_migrated").join(v.to_string()).join(fname),
content,
)
.unwrap();
}
std::fs::write(root.join("migration-order.yaml"), yaml).unwrap();
let driver = PostgresDriver::new(pool.clone(), pg_config(&schema)).unwrap();
let migrator = Migrator::from_root(&root);
migrator
.up(&driver)
.await
.expect("numeric version ordering should work");
let rows: Vec<(i32,)> = sqlx::query_as(&format!(
"SELECT version FROM \"{schema}\".\"00_schema_migrations\" ORDER BY version"
))
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(rows.iter().map(|r| r.0).collect::<Vec<_>>(), vec![1, 2, 10]);
guard.cleanup().await;
}
#[tokio::test]
async fn test_setup_runs_before_migrations_and_is_idempotent() {
let pool = make_pool().await;
let guard = make_schema(&pool).await;
let schema = guard.schema.clone();
let setup_sql =
format!("CREATE TABLE IF NOT EXISTS \"{schema}\".setup_marker (id SERIAL PRIMARY KEY);");
let migration_sql = format!(
"INSERT INTO \"{schema}\".setup_marker DEFAULT VALUES;\n-- DOWN ==\nDELETE FROM \"{schema}\".setup_marker;"
);
let f = MigrationsFixture::build(
Some(&setup_sql),
&[(1, vec![("20260101_01_uses_setup.sql", &migration_sql)])],
None,
);
let driver = PostgresDriver::new(pool.clone(), pg_config(&schema)).unwrap();
let migrator = Migrator::from_root(&f.root);
migrator.up(&driver).await.expect("first up should succeed");
migrator
.up(&driver)
.await
.expect("second up should be a no-op");
let count: (i64,) = sqlx::query_as(&format!(
"SELECT COUNT(*) FROM \"{schema}\".\"00_schema_migrations\""
))
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count.0, 1, "still only one migration row after second up");
guard.cleanup().await;
}
#[tokio::test]
async fn test_setup_files_never_orphan_flagged() {
let pool = make_pool().await;
let guard = make_schema(&pool).await;
let schema = guard.schema.clone();
let setup_sql = "SELECT 1;";
let migration_sql = format!(
"CREATE TABLE \"{schema}\".tbl_x (id SERIAL PRIMARY KEY);\n-- DOWN ==\nDROP TABLE IF EXISTS \"{schema}\".tbl_x;"
);
let f = MigrationsFixture::build(
Some(setup_sql),
&[(1, vec![("20260101_01_init.sql", &migration_sql)])],
None,
);
std::fs::write(f.root.join("00_setup").join("02_extra.sql"), "SELECT 2;").unwrap();
let driver = PostgresDriver::new(pool.clone(), pg_config(&schema)).unwrap();
let migrator = Migrator::from_root(&f.root);
migrator
.up(&driver)
.await
.expect("extra setup file should not be an orphan");
guard.cleanup().await;
}
#[tokio::test]
async fn test_orphan_migration_returns_error() {
let dir = TempDir::new().unwrap();
let root = dir.path().to_path_buf();
let yaml = "manifest_version: 1\nversions:\n - version: 1\n migrations:\n - file: \"listed.sql\"\n";
std::fs::create_dir_all(root.join("01_migrated").join("1")).unwrap();
std::fs::write(
root.join("01_migrated").join("1").join("listed.sql"),
"SELECT 1;",
)
.unwrap();
std::fs::write(
root.join("01_migrated").join("1").join("orphan.sql"),
"SELECT 2;",
)
.unwrap();
std::fs::write(root.join("migration-order.yaml"), yaml).unwrap();
let result = soma_schema::discovery::discover(&root);
assert!(
matches!(result, Err(Error::OrphanMigration { .. })),
"expected OrphanMigration, got: {result:?}"
);
}
#[tokio::test]
async fn test_missing_file_returns_error() {
let dir = TempDir::new().unwrap();
let root = dir.path().to_path_buf();
let yaml = "manifest_version: 1\nversions:\n - version: 1\n migrations:\n - file: \"ghost.sql\"\n";
std::fs::create_dir_all(root.join("01_migrated").join("1")).unwrap();
std::fs::write(root.join("migration-order.yaml"), yaml).unwrap();
let result = soma_schema::discovery::discover(&root);
assert!(
matches!(result, Err(Error::MissingFile { .. })),
"expected MissingFile, got: {result:?}"
);
}
#[tokio::test]
async fn test_invalid_filename_in_manifest_rejected() {
let yaml = r#"manifest_version: 1
versions:
- version: 1
migrations:
- file: "../evil.sql"
"#;
let result = soma_schema::manifest::Manifest::from_yaml(yaml);
assert!(
matches!(result, Err(Error::InvalidFileName(_))),
"expected InvalidFileName, got: {result:?}"
);
}
#[tokio::test]
async fn test_identifier_validation() {
let pool = make_pool().await;
let result = PostgresDriver::new(
pool.clone(),
PostgresConfig {
schema: Some("bad-schema".to_owned()),
table: "00_schema_migrations".to_owned(),
advisory_lock_key: 918273645,
},
);
assert!(
matches!(result, Err(Error::InvalidIdentifier(_))),
"bad schema name should be rejected"
);
let ok = PostgresDriver::new(
pool.clone(),
PostgresConfig {
schema: Some("00_schema".to_owned()),
table: "00_schema_migrations".to_owned(),
advisory_lock_key: 918273645,
},
);
assert!(ok.is_ok(), "leading-digit identifier should be accepted");
}
#[tokio::test]
async fn test_deployment_tracking_metadata() {
let pool = make_pool().await;
let guard = make_schema(&pool).await;
let schema = guard.schema.clone();
let sql = with_schema(&migration_create_table("tbl_meta"), &schema);
let yaml = r#"manifest_version: 1
versions:
- version: 1
migrations:
- file: "20260101_01_init.sql"
why: "Track metadata"
"#;
let dir = TempDir::new().unwrap();
let root = dir.path().to_path_buf();
std::fs::create_dir_all(root.join("01_migrated").join("1")).unwrap();
std::fs::write(
root.join("01_migrated")
.join("1")
.join("20260101_01_init.sql"),
&sql,
)
.unwrap();
std::fs::write(root.join("migration-order.yaml"), yaml).unwrap();
let driver = PostgresDriver::new(pool.clone(), pg_config(&schema)).unwrap();
let migrator = Migrator::from_root(&root);
migrator.up(&driver).await.unwrap();
let row: (
i32,
String,
Option<String>,
i32,
sqlx::types::chrono::DateTime<sqlx::types::chrono::Utc>,
String,
Option<i32>,
) = sqlx::query_as(&format!(
"SELECT version, file, description, batch, applied_at, applied_by, execution_ms \
FROM \"{schema}\".\"00_schema_migrations\""
))
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(row.0, 1, "version");
assert_eq!(row.1, "20260101_01_init.sql", "file");
assert_eq!(
row.2.as_deref(),
Some("Track metadata"),
"description from why"
);
assert_eq!(row.3, 1, "first batch");
let age = sqlx::types::chrono::Utc::now() - row.4;
assert!(age.num_seconds() < 60, "applied_at should be recent");
assert!(!row.5.is_empty(), "applied_by should not be empty");
assert!(row.6.is_some(), "execution_ms should be set");
guard.cleanup().await;
}
#[tokio::test]
async fn test_batch_increments_across_separate_up_runs() {
let pool = make_pool().await;
let guard = make_schema(&pool).await;
let schema = guard.schema.clone();
let sql1 = with_schema(&migration_create_table("tbl_batch1"), &schema);
let sql2 = with_schema(&migration_create_table("tbl_batch2"), &schema);
let dir = TempDir::new().unwrap();
let root = dir.path().to_path_buf();
std::fs::create_dir_all(root.join("01_migrated").join("1")).unwrap();
std::fs::write(root.join("01_migrated").join("1").join("m1.sql"), &sql1).unwrap();
let driver = PostgresDriver::new(pool.clone(), pg_config(&schema)).unwrap();
let migrator = Migrator::from_root(&root);
let yaml_v1_only = r#"manifest_version: 1
versions:
- version: 1
migrations:
- file: "m1.sql"
"#;
std::fs::write(root.join("migration-order.yaml"), yaml_v1_only).unwrap();
migrator.up(&driver).await.unwrap();
std::fs::create_dir_all(root.join("01_migrated").join("2")).unwrap();
std::fs::write(root.join("01_migrated").join("2").join("m2.sql"), &sql2).unwrap();
let yaml_both = r#"manifest_version: 1
versions:
- version: 1
migrations:
- file: "m1.sql"
- version: 2
migrations:
- file: "m2.sql"
"#;
std::fs::write(root.join("migration-order.yaml"), yaml_both).unwrap();
migrator.up(&driver).await.unwrap();
let rows: Vec<(String, i32)> = sqlx::query_as(&format!(
"SELECT file, batch FROM \"{schema}\".\"00_schema_migrations\" ORDER BY file"
))
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(rows.len(), 2);
let m1 = rows.iter().find(|r| r.0 == "m1.sql").unwrap();
let m2 = rows.iter().find(|r| r.0 == "m2.sql").unwrap();
assert_eq!(m1.1, 1, "m1 batch should be 1");
assert_eq!(m2.1, 2, "m2 batch should be 2 (separate run)");
guard.cleanup().await;
}
#[tokio::test]
async fn test_idempotent_seed_applied_once() {
let pool = make_pool().await;
let guard = make_schema(&pool).await;
let schema = guard.schema.clone();
let create_sql = format!(
"CREATE TABLE \"{schema}\".seeds (id SERIAL PRIMARY KEY, name TEXT UNIQUE);\n-- DOWN ==\nDROP TABLE IF EXISTS \"{schema}\".seeds;"
);
let seed_sql = format!(
"INSERT INTO \"{schema}\".seeds (name) VALUES ('alpha') ON CONFLICT DO NOTHING;\n-- DOWN ==\nDELETE FROM \"{schema}\".seeds WHERE name = 'alpha';"
);
let f = MigrationsFixture::build(
Some("SELECT 1;"),
&[(
1,
vec![
("20260101_01_create.sql", &create_sql),
("20260101_02_seed.sql", &seed_sql),
],
)],
None,
);
let driver = PostgresDriver::new(pool.clone(), pg_config(&schema)).unwrap();
let migrator = Migrator::from_root(&f.root);
migrator.up(&driver).await.unwrap();
migrator.up(&driver).await.unwrap();
let count: (i64,) = sqlx::query_as(&format!(
"SELECT COUNT(*) FROM \"{schema}\".seeds WHERE name = 'alpha'"
))
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count.0, 1, "seed row should appear exactly once");
guard.cleanup().await;
}
#[tokio::test]
async fn test_down_reverts_in_reverse_manifest_order_not_filename_sort() {
let pool = make_pool().await;
let guard = make_schema(&pool).await;
let schema = guard.schema.clone();
let sql_z = format!(
"CREATE TABLE \"{schema}\".rev_order_z (id SERIAL PRIMARY KEY);\n-- DOWN ==\nDROP TABLE IF EXISTS \"{schema}\".rev_order_z;"
);
let sql_a = format!(
"INSERT INTO \"{schema}\".rev_order_z DEFAULT VALUES;\n-- DOWN ==\nDELETE FROM \"{schema}\".rev_order_z;"
);
let yaml = r#"manifest_version: 1
versions:
- version: 1
migrations:
- file: "z_first.sql"
- file: "a_second.sql"
"#;
let dir = TempDir::new().unwrap();
let root = dir.path().to_path_buf();
std::fs::create_dir_all(root.join("01_migrated").join("1")).unwrap();
std::fs::write(
root.join("01_migrated").join("1").join("z_first.sql"),
&sql_z,
)
.unwrap();
std::fs::write(
root.join("01_migrated").join("1").join("a_second.sql"),
&sql_a,
)
.unwrap();
std::fs::write(root.join("migration-order.yaml"), yaml).unwrap();
let driver = PostgresDriver::new(pool.clone(), pg_config(&schema)).unwrap();
let migrator = Migrator::from_root(&root);
migrator
.up(&driver)
.await
.expect("up with non-alpha manifest order should succeed");
migrator
.down(&driver, 1)
.await
.expect("down 1 should succeed");
let tbl_exists: (bool,) = sqlx::query_as(
"SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = $1 AND table_name = $2)"
)
.bind(&schema)
.bind("rev_order_z")
.fetch_one(&pool)
.await
.unwrap();
assert!(
tbl_exists.0,
"rev_order_z should still exist after down(1) reverts a_second, not z_first"
);
let rows: Vec<(String,)> = sqlx::query_as(&format!(
"SELECT file FROM \"{schema}\".\"00_schema_migrations\""
))
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(
rows[0].0, "z_first.sql",
"z_first.sql should still be in tracking after down(1)"
);
guard.cleanup().await;
}
#[tokio::test]
async fn test_orphan_in_unregistered_version_folder_returns_error() {
let dir = TempDir::new().unwrap();
let root = dir.path().to_path_buf();
let yaml = "manifest_version: 1\nversions:\n - version: 1\n migrations:\n - file: \"ok.sql\"\n";
std::fs::create_dir_all(root.join("01_migrated").join("1")).unwrap();
std::fs::write(
root.join("01_migrated").join("1").join("ok.sql"),
"SELECT 1;",
)
.unwrap();
std::fs::create_dir_all(root.join("01_migrated").join("99")).unwrap();
std::fs::write(
root.join("01_migrated").join("99").join("surprise.sql"),
"SELECT 99;",
)
.unwrap();
std::fs::write(root.join("migration-order.yaml"), yaml).unwrap();
let result = soma_schema::discovery::discover(&root);
assert!(
matches!(result, Err(Error::OrphanMigration { .. })),
"expected OrphanMigration for unregistered version folder, got: {result:?}"
);
}
#[tokio::test]
async fn test_applied_but_missing_from_manifest_returns_error() {
let pool = make_pool().await;
let guard = make_schema(&pool).await;
let schema = guard.schema.clone();
let sql = with_schema(&migration_create_table("tbl_ghost"), &schema);
let f = MigrationsFixture::build(
Some("SELECT 1;"),
&[(1, vec![("20260101_01_init.sql", &sql)])],
None,
);
let driver = PostgresDriver::new(pool.clone(), pg_config(&schema)).unwrap();
let migrator = Migrator::from_root(&f.root);
migrator.up(&driver).await.unwrap();
std::fs::remove_file(
f.root
.join("01_migrated")
.join("1")
.join("20260101_01_init.sql"),
)
.unwrap();
std::fs::write(
f.root.join("migration-order.yaml"),
"manifest_version: 1\nversions:\n - version: 1\n migrations: []\n",
)
.unwrap();
let result = migrator.up(&driver).await;
assert!(
matches!(result, Err(Error::AppliedButMissing { .. })),
"expected AppliedButMissing when applied DB row has no on-disk match, got: {result:?}"
);
guard.cleanup().await;
}
#[tokio::test]
async fn test_pool_too_small_rejected() {
let pool = PgPoolOptions::new()
.max_connections(1)
.connect(&test_db_url())
.await
.expect("connect");
let result = PostgresDriver::new(pool, pg_config("dummy"));
assert!(
matches!(result, Err(Error::PoolTooSmall)),
"expected PoolTooSmall, got: {result:?}"
);
}
#[tokio::test]
async fn test_status_reports_applied_and_pending() {
let pool = make_pool().await;
let guard = make_schema(&pool).await;
let schema = guard.schema.clone();
let sql1 = with_schema(&migration_create_table("tbl_s1"), &schema);
let sql2 = with_schema(&migration_create_table("tbl_s2"), &schema);
let f = MigrationsFixture::build(
Some("SELECT 1;"),
&[(1, vec![("20260101_01_a.sql", &sql1)])],
None,
);
let driver = PostgresDriver::new(pool.clone(), pg_config(&schema)).unwrap();
let migrator = Migrator::from_root(&f.root);
migrator.up(&driver).await.unwrap();
std::fs::write(
f.root
.join("01_migrated")
.join("1")
.join("20260101_02_b.sql"),
&sql2,
)
.unwrap();
let yaml_both = r#"manifest_version: 1
versions:
- version: 1
migrations:
- file: "20260101_01_a.sql"
- file: "20260101_02_b.sql"
"#;
std::fs::write(f.root.join("migration-order.yaml"), yaml_both).unwrap();
let status = migrator
.status(&driver)
.await
.expect("status() should succeed");
assert_eq!(status.applied.len(), 1, "one migration applied");
assert_eq!(status.pending.len(), 1, "one migration pending");
assert_eq!(status.applied[0].file, "20260101_01_a.sql");
assert_eq!(status.pending[0].file, "20260101_02_b.sql");
assert!(status.drift_errors.is_empty(), "no drift expected");
guard.cleanup().await;
}
#[tokio::test]
async fn test_status_reports_drift_without_aborting() {
let pool = make_pool().await;
let guard = make_schema(&pool).await;
let schema = guard.schema.clone();
let original_sql = with_schema(&migration_create_table("tbl_drift_status"), &schema);
let f = MigrationsFixture::build(
Some("SELECT 1;"),
&[(1, vec![("20260101_01_init.sql", &original_sql)])],
None,
);
let driver = PostgresDriver::new(pool.clone(), pg_config(&schema)).unwrap();
let migrator = Migrator::from_root(&f.root);
migrator.up(&driver).await.unwrap();
std::fs::write(
f.root
.join("01_migrated")
.join("1")
.join("20260101_01_init.sql"),
format!("{original_sql}\n-- drift comment"),
)
.unwrap();
let status = migrator
.status(&driver)
.await
.expect("status() should return Ok even with drift");
assert!(
!status.drift_errors.is_empty(),
"drift_errors should be non-empty when file was modified"
);
guard.cleanup().await;
}
#[tokio::test]
async fn test_down_detects_drift() {
let pool = make_pool().await;
let guard = make_schema(&pool).await;
let schema = guard.schema.clone();
let original_sql = with_schema(&migration_create_table("tbl_down_drift"), &schema);
let f = MigrationsFixture::build(
Some("SELECT 1;"),
&[(1, vec![("20260101_01_init.sql", &original_sql)])],
None,
);
let driver = PostgresDriver::new(pool.clone(), pg_config(&schema)).unwrap();
let migrator = Migrator::from_root(&f.root);
migrator.up(&driver).await.unwrap();
std::fs::write(
f.root
.join("01_migrated")
.join("1")
.join("20260101_01_init.sql"),
format!("{original_sql}\n-- drift"),
)
.unwrap();
let result = migrator.down(&driver, 1).await;
match result {
Err(Error::ChecksumDrift { .. }) => {} Err(e) => panic!("expected ChecksumDrift, got: {e:?}"),
Ok(_) => panic!("expected Err(ChecksumDrift), got Ok"),
}
guard.cleanup().await;
}
#[tokio::test]
async fn test_status_does_not_block_during_up() {
let pool = make_pool().await;
let guard = make_schema(&pool).await;
let schema = guard.schema.clone();
let sql = with_schema(&migration_create_table("tbl_concurrent"), &schema);
let f = MigrationsFixture::build(
Some("SELECT 1;"),
&[(1, vec![("20260101_01_init.sql", &sql)])],
None,
);
let driver_up = PostgresDriver::new(pool.clone(), pg_config(&schema)).unwrap();
let driver_status = PostgresDriver::new(
pool.clone(),
PostgresConfig {
schema: Some(schema.clone()),
table: "00_schema_migrations".to_owned(),
advisory_lock_key: 918273646, },
)
.unwrap();
let migrator = Migrator::from_root(&f.root);
migrator.up(&driver_up).await.unwrap();
let status = migrator
.status(&driver_status)
.await
.expect("status() should return Ok");
assert_eq!(status.applied.len(), 1);
guard.cleanup().await;
}