#![allow(dead_code, private_interfaces)]
use std::collections::HashSet;
use std::path::Path;
use tokio::sync::{Mutex, OnceCell};
use umbral::migrate::{
APP_PLUGIN_NAME, Column, DriftReport, MigrateError, MigrationEntry, MigrationFile,
MigrationStatus, Operation, Snapshot, detect_drift, fake_apply_in, fake_initial_in,
run_checked_in,
};
use umbral::orm::SqlType;
use umbral_core::orm::Post;
static BOOT: OnceCell<()> = OnceCell::const_new();
static POOL_LOCK: Mutex<()> = Mutex::const_new(());
async fn boot() {
BOOT.get_or_init(|| async {
let settings =
umbral::Settings::from_env().expect("figment defaults always load in a test env");
let pool = umbral::db::connect_sqlite("sqlite::memory:")
.await
.expect("in-memory sqlite");
umbral::App::builder()
.settings(settings)
.database("default", pool)
.model::<Post>()
.build()
.expect("App::build() should succeed");
})
.await;
}
fn write_migration(dir: &Path, plugin: &str, id: &str, table: &str) {
let plugin_dir = dir.join(plugin);
std::fs::create_dir_all(&plugin_dir).expect("mkdir plugin_dir");
let file = MigrationFile {
id: id.to_string(),
plugin: plugin.to_string(),
depends_on: Vec::new(),
operations: vec![Operation::CreateTable {
table: table.to_string(),
columns: vec![
Column {
name: "id".to_string(),
ty: SqlType::BigInt,
primary_key: true,
nullable: false,
fk_target: None,
noform: false,
db_constraint: true,
noedit: false,
is_string_repr: false,
max_length: 0,
choices: Vec::new(),
choice_labels: Vec::new(),
default: String::new(),
is_multichoice: false,
unique: false,
on_delete: umbral_core::orm::FkAction::NoAction,
on_update: umbral_core::orm::FkAction::NoAction,
index: false,
auto_now_add: false,
auto_now: false,
help: String::new(),
example: String::new(),
widget: None,
supported_backends: Vec::new(),
min: None,
max: None,
text_format: ::core::option::Option::None,
slug_from: ::core::option::Option::None,
},
Column {
name: "title".to_string(),
ty: SqlType::Text,
primary_key: false,
nullable: false,
fk_target: None,
noform: false,
db_constraint: true,
noedit: false,
is_string_repr: false,
max_length: 0,
choices: Vec::new(),
choice_labels: Vec::new(),
default: String::new(),
is_multichoice: false,
unique: false,
on_delete: umbral_core::orm::FkAction::NoAction,
on_update: umbral_core::orm::FkAction::NoAction,
index: false,
auto_now_add: false,
auto_now: false,
help: String::new(),
example: String::new(),
widget: None,
supported_backends: Vec::new(),
min: None,
max: None,
text_format: ::core::option::Option::None,
slug_from: ::core::option::Option::None,
},
],
unique_together: Vec::new(),
indexes: Vec::new(),
}],
snapshot_after: Snapshot::default(),
};
let json = serde_json::to_string_pretty(&file).expect("serialize");
std::fs::write(plugin_dir.join(format!("{id}.json")), json).expect("write migration");
}
fn set(pairs: &[(&str, &str)]) -> HashSet<(String, String)> {
pairs
.iter()
.map(|(p, n)| (p.to_string(), n.to_string()))
.collect()
}
async fn mark_applied_shared(plugin: &str, name: &str) {
let pool = umbral::db::pool();
sqlx::query(
"CREATE TABLE IF NOT EXISTS umbral_migrations (\
plugin TEXT NOT NULL, \
name TEXT NOT NULL, \
applied_at TEXT NOT NULL, \
snapshot_hash TEXT NOT NULL, \
PRIMARY KEY (plugin, name)\
)",
)
.execute(&pool)
.await
.expect("ensure umbral_migrations table");
sqlx::query(
"INSERT OR IGNORE INTO umbral_migrations (plugin, name, applied_at, snapshot_hash) \
VALUES (?, ?, '2026-01-01T00:00:00Z', 'deadbeef')",
)
.bind(plugin)
.bind(name)
.execute(&pool)
.await
.expect("mark_applied_shared");
}
#[test]
fn drift_detected_when_tracking_table_has_migration_missing_on_disk() {
let tmp = tempfile::tempdir().expect("tempdir");
let applied = set(&[(APP_PLUGIN_NAME, "0001_initial")]);
let plugin_dir = tmp.path().join(APP_PLUGIN_NAME);
let entries = detect_drift(APP_PLUGIN_NAME, &applied, &plugin_dir)
.expect("detect_drift should succeed even when dir is absent");
assert_eq!(
entries.len(),
1,
"one entry expected (the missing migration); got {entries:?}",
);
assert_eq!(entries[0].plugin, APP_PLUGIN_NAME);
assert_eq!(entries[0].name, "0001_initial");
assert_eq!(
entries[0].status,
MigrationStatus::AppliedButMissing,
"a tracking-set row with no file must be AppliedButMissing",
);
let report = DriftReport { entries };
assert!(
report.has_critical_drift(),
"has_critical_drift must be true when any AppliedButMissing entry exists",
);
assert_eq!(report.missing_on_disk().len(), 1);
}
#[test]
fn detect_drift_classifies_out_of_order_correctly() {
let tmp = tempfile::tempdir().expect("tempdir");
write_migration(tmp.path(), APP_PLUGIN_NAME, "0001_initial", "oo_t1");
write_migration(tmp.path(), APP_PLUGIN_NAME, "0002_restored", "oo_t2");
write_migration(tmp.path(), APP_PLUGIN_NAME, "0003_later", "oo_t3");
let applied = set(&[
(APP_PLUGIN_NAME, "0001_initial"),
(APP_PLUGIN_NAME, "0003_later"),
]);
let entries = detect_drift(APP_PLUGIN_NAME, &applied, &tmp.path().join(APP_PLUGIN_NAME))
.expect("detect_drift should not error");
let find = |name: &str| -> &MigrationEntry {
entries
.iter()
.find(|e| e.name == name)
.unwrap_or_else(|| panic!("entry `{name}` not found in {entries:?}"))
};
assert_eq!(find("0001_initial").status, MigrationStatus::Applied);
assert_eq!(
find("0002_restored").status,
MigrationStatus::OutOfOrder,
"0002 has seq 2 < max_applied_seq 3, so it must be OutOfOrder",
);
assert_eq!(find("0003_later").status, MigrationStatus::Applied);
}
#[test]
fn detect_drift_classifies_pending_correctly() {
let tmp = tempfile::tempdir().expect("tempdir");
write_migration(tmp.path(), APP_PLUGIN_NAME, "0001_initial", "p_t1");
write_migration(tmp.path(), APP_PLUGIN_NAME, "0002_pending", "p_t2");
let applied = set(&[(APP_PLUGIN_NAME, "0001_initial")]);
let entries = detect_drift(APP_PLUGIN_NAME, &applied, &tmp.path().join(APP_PLUGIN_NAME))
.expect("detect_drift should not error");
let find = |name: &str| -> &MigrationEntry {
entries
.iter()
.find(|e| e.name == name)
.unwrap_or_else(|| panic!("entry `{name}` not found in {entries:?}"))
};
assert_eq!(find("0001_initial").status, MigrationStatus::Applied);
assert_eq!(
find("0002_pending").status,
MigrationStatus::Pending,
"0002 has seq 2 > max_applied_seq 1 and is not in the DB, so it must be Pending",
);
}
#[test]
fn drift_report_no_critical_drift_for_out_of_order_or_pending() {
let tmp = tempfile::tempdir().expect("tempdir");
write_migration(tmp.path(), APP_PLUGIN_NAME, "0001_initial", "ncd_t1");
write_migration(tmp.path(), APP_PLUGIN_NAME, "0002_pending", "ncd_t2");
let applied = set(&[(APP_PLUGIN_NAME, "0001_initial")]);
let entries = detect_drift(APP_PLUGIN_NAME, &applied, &tmp.path().join(APP_PLUGIN_NAME))
.expect("detect_drift should not error");
let report = DriftReport { entries };
assert!(
!report.has_critical_drift(),
"OutOfOrder and Pending must not be critical drift; got {:?}",
report.entries,
);
}
#[tokio::test]
async fn run_checked_in_errors_on_drift_without_allow_drift() {
boot().await;
let _lock = POOL_LOCK.lock().await;
let tmp = tempfile::tempdir().expect("tempdir");
mark_applied_shared(APP_PLUGIN_NAME, "0001_ghost_rce").await;
write_migration(
tmp.path(),
APP_PLUGIN_NAME,
"0002_pending_rce",
"rce_pending_table",
);
let err = run_checked_in(tmp.path(), false)
.await
.expect_err("should error on drift without allow_drift");
match err {
MigrateError::DriftDetected { missing } => {
let has_ghost = missing
.iter()
.any(|(p, n)| p == APP_PLUGIN_NAME && n == "0001_ghost_rce");
assert!(
has_ghost,
"DriftDetected must name the ghost migration; got {missing:?}",
);
}
other => panic!("expected MigrateError::DriftDetected, got {other:?}"),
}
}
#[tokio::test]
async fn run_checked_in_proceeds_with_allow_drift() {
boot().await;
let _lock = POOL_LOCK.lock().await;
let tmp = tempfile::tempdir().expect("tempdir");
mark_applied_shared(APP_PLUGIN_NAME, "0001_ghost_rad").await;
write_migration(
tmp.path(),
APP_PLUGIN_NAME,
"0002_pending_rad",
"rad_allow_table",
);
let n = run_checked_in(tmp.path(), true)
.await
.expect("run_checked_in with allow_drift should not error");
assert!(
n >= 1,
"at least one migration should have been applied; got {n}",
);
let pool = umbral::db::pool();
let rows: Vec<(String, String)> =
sqlx::query_as("SELECT plugin, name FROM umbral_migrations WHERE name = '0002_pending_rad'")
.fetch_all(&pool)
.await
.expect("select");
assert_eq!(
rows.len(),
1,
"0002_pending_rad should be tracked after apply with allow_drift",
);
let exists: Option<(String,)> = sqlx::query_as(
"SELECT name FROM sqlite_master WHERE type='table' AND name='rad_allow_table'",
)
.fetch_optional(&pool)
.await
.expect("sqlite_master check");
assert!(
exists.is_some(),
"rad_allow_table should exist after apply with allow_drift",
);
}
#[tokio::test]
async fn fake_apply_in_marks_applied_without_schema_change() {
boot().await;
let _lock = POOL_LOCK.lock().await;
let tmp = tempfile::tempdir().expect("tempdir");
write_migration(
tmp.path(),
APP_PLUGIN_NAME,
"0001_fake_fa",
"fa_target_table",
);
fake_apply_in(APP_PLUGIN_NAME, "0001_fake_fa", tmp.path())
.await
.expect("fake_apply_in should succeed");
let pool = umbral::db::pool();
let rows: Vec<(String, String)> =
sqlx::query_as("SELECT plugin, name FROM umbral_migrations WHERE name = '0001_fake_fa'")
.fetch_all(&pool)
.await
.expect("select");
assert_eq!(
rows.len(),
1,
"tracking table should have the fake-applied row; got {rows:?}",
);
let exists: Option<(String,)> = sqlx::query_as(
"SELECT name FROM sqlite_master WHERE type='table' AND name='fa_target_table'",
)
.fetch_optional(&pool)
.await
.expect("sqlite_master check");
assert!(
exists.is_none(),
"fake_apply_in must not run DDL; fa_target_table should not exist",
);
fake_apply_in(APP_PLUGIN_NAME, "0001_fake_fa", tmp.path())
.await
.expect("second fake_apply_in should succeed");
let count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM umbral_migrations WHERE name = '0001_fake_fa'")
.fetch_one(&pool)
.await
.expect("count");
assert_eq!(
count, 1,
"idempotent fake_apply_in must not insert duplicate rows; got {count}",
);
}
#[tokio::test]
async fn fake_initial_in_skips_when_tables_absent() {
boot().await;
let _lock = POOL_LOCK.lock().await;
let tmp = tempfile::tempdir().expect("tempdir");
write_migration(
tmp.path(),
APP_PLUGIN_NAME,
"0001_fi_absent",
"fi_absent_table_xyz",
);
let n = fake_initial_in(tmp.path())
.await
.expect("fake_initial_in should not error");
assert_eq!(
n, 0,
"fake_initial_in should return 0 when tables are absent; got {n}",
);
let pool = umbral::db::pool();
let rows: Vec<(String, String)> =
sqlx::query_as("SELECT plugin, name FROM umbral_migrations WHERE name = '0001_fi_absent'")
.fetch_all(&pool)
.await
.expect("select");
assert!(
rows.is_empty(),
"tracking table should not have the row when tables are absent; got {rows:?}",
);
}
#[tokio::test]
async fn fake_initial_in_marks_applied_when_tables_exist() {
boot().await;
let _lock = POOL_LOCK.lock().await;
let tmp = tempfile::tempdir().expect("tempdir");
write_migration(
tmp.path(),
APP_PLUGIN_NAME,
"0001_fi_exists",
"fi_existing_tbl",
);
let pool = umbral::db::pool();
sqlx::query(
"CREATE TABLE IF NOT EXISTS fi_existing_tbl (id INTEGER PRIMARY KEY, title TEXT NOT NULL)",
)
.execute(&pool)
.await
.expect("create fi_existing_tbl");
let n = fake_initial_in(tmp.path())
.await
.expect("fake_initial_in should succeed");
assert_eq!(
n, 1,
"fake_initial_in should return 1 when tables exist; got {n}",
);
let rows: Vec<(String, String)> =
sqlx::query_as("SELECT plugin, name FROM umbral_migrations WHERE name = '0001_fi_exists'")
.fetch_all(&pool)
.await
.expect("select");
assert_eq!(
rows.len(),
1,
"tracking table should have the row after fake_initial_in; got {rows:?}",
);
let n2 = fake_initial_in(tmp.path())
.await
.expect("second fake_initial_in should succeed");
assert_eq!(
n2, 0,
"second call should be a no-op (already in tracking table); got {n2}",
);
}