//! Startup closes a schema gap it did not open. //! //! The state under test is a database left at an older migration because the //! desktop app has not been launched since newer ones landed. That schema is //! built by applying every //! migration file but the last few and recording them in the ledger exactly as //! the runner would, so the test does not name a version and does not go stale //! when the next migration lands. use std::path::{Path, PathBuf}; use rusqlite::Connection; use sha2::{Digest, Sha384}; /// How many of the newest migrations the seeded database is missing. const WITHHELD: usize = 3; fn migrations_dir() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("../../migrations/sqlite") } /// Every migration file, in version order, as (version, description, sql). fn migration_files() -> Vec<(i64, String, String)> { let mut out: Vec<(i64, String, String)> = std::fs::read_dir(migrations_dir()) .expect("read the migrations directory") .filter_map(|entry| { let path = entry.expect("read a directory entry").path(); if path.extension().is_none_or(|ext| ext != "sql") { return None; } let stem = path.file_stem()?.to_str()?.to_owned(); let (version, description) = stem.split_once('_')?; Some(( version.parse::().expect("a numeric migration prefix"), description.to_owned(), std::fs::read_to_string(&path).expect("read a migration"), )) }) .collect(); out.sort_by_key(|(version, _, _)| *version); assert!( out.len() > WITHHELD, "not enough migrations to withhold any" ); out } /// Versions recorded in the ledger, in order. fn applied(conn: &Connection) -> Vec { let mut stmt = conn .prepare("SELECT version FROM _sqlx_migrations ORDER BY version") .expect("prepare the ledger read"); let rows = stmt .query_map([], |row| row.get::<_, i64>(0)) .expect("query the ledger"); rows.collect::>().expect("collect the ledger") } /// Write a database that stops short of the newest migrations. fn seed_old_schema(path: &Path, files: &[(i64, String, String)]) { let conn = Connection::open(path).expect("open the seed database"); conn.execute_batch( "CREATE TABLE IF NOT EXISTS _sqlx_migrations ( version BIGINT PRIMARY KEY, description TEXT NOT NULL, installed_on TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, success BOOLEAN NOT NULL, checksum BLOB NOT NULL, execution_time BIGINT NOT NULL );", ) .expect("create the ledger"); for (version, description, sql) in &files[..files.len() - WITHHELD] { conn.execute_batch(sql) .unwrap_or_else(|e| panic!("apply migration {version}: {e}")); conn.execute( "INSERT INTO _sqlx_migrations \ (version, description, success, checksum, execution_time) \ VALUES (?1, ?2, TRUE, ?3, 0)", rusqlite::params![ version, description, Sha384::digest(sql.as_bytes()).to_vec() ], ) .expect("record the migration"); } } #[test] fn startup_migrates_a_database_left_at_an_older_schema() { let files = migration_files(); let dir = std::env::temp_dir().join(format!("go-mcp-startup-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&dir).expect("make a scratch directory"); let path = dir.join("goingson.db"); seed_old_schema(&path, &files); // The seeded database is genuinely behind. let seeded = applied(&Connection::open(&path).expect("reopen the seed")); assert_eq!(seeded.len(), files.len() - WITHHELD); let db = goingson_db_sqlite::init_pool(Some(&path.to_string_lossy())).expect("open the pool"); go_mcp::startup::migrate(&db, &path).expect("startup migrates the gap away"); let after = applied(&db.conn().expect("check out a connection")); let expected: Vec = files.iter().map(|(version, _, _)| *version).collect(); assert_eq!(after, expected, "startup left migrations unapplied"); // Idempotent: a second startup against the same file is a no-op. go_mcp::startup::migrate(&db, &path).expect("second startup is clean"); assert_eq!( applied(&db.conn().expect("check out a connection")), expected ); drop(db); let _ = std::fs::remove_dir_all(&dir); }