use crate::config::profile::with_home_override_async;
use crate::db::Database;
use crate::db::database::MIGRATION_SQL;
use crate::db::migration_heal::{heals_would_write, thread_id_migration_index};
use crate::db::migration_snapshot::{
LATEST, PREFIX, RETENTION, RETENTION_BYTES, collect_dated, prune_dated, rotate,
snapshot_before_migrations,
};
use rusqlite::{Connection, OpenFlags};
use std::path::{Path, PathBuf};
fn seeded_db(dir: &Path) -> PathBuf {
let path = dir.join("opencrabs.db");
let conn = Connection::open(&path).unwrap();
conn.execute("CREATE TABLE canary(x TEXT)", []).unwrap();
conn.execute("INSERT INTO canary VALUES ('nicole')", [])
.unwrap();
drop(conn);
path
}
fn tables_in(path: &Path) -> Vec<String> {
let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_WRITE).unwrap();
let mut stmt = conn
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
.unwrap();
let rows: Vec<String> = stmt
.query_map([], |r| r.get::<_, String>(0))
.unwrap()
.map(|r| r.unwrap())
.collect();
rows
}
fn dated_snapshots(dir: &Path) -> Vec<PathBuf> {
let mut v: Vec<PathBuf> = std::fs::read_dir(dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with(PREFIX) && n != LATEST)
.unwrap_or(false)
})
.collect();
v.sort();
v
}
#[tokio::test]
async fn non_empty_db_is_snapshotted_before_migrations_run() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join(".opencrabs");
std::fs::create_dir_all(&home).unwrap();
let db_path = seeded_db(&home);
let db = with_home_override_async(home.clone(), async {
let db = Database::connect(&db_path).await.unwrap();
db.run_migrations().await.unwrap();
db
})
.await;
let _ = db;
let backups = home.join("backups");
let snaps = dated_snapshots(&backups);
assert_eq!(
snaps.len(),
1,
"exactly one pre-migration snapshot expected"
);
let names = tables_in(&snaps[0]);
assert!(
names.contains(&"canary".to_string()),
"snapshot must carry the pre-migration data, got {names:?}"
);
assert!(
!names.contains(&"sessions".to_string()),
"snapshot was taken AFTER migrations ran, not before: got {names:?}"
);
let latest = backups.join(LATEST);
assert!(latest.exists(), "-latest alias must always name a file");
assert_eq!(
std::fs::read(&latest).unwrap().len(),
std::fs::read(&snaps[0]).unwrap().len(),
"-latest must name the same bytes as the newest snapshot"
);
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let alias = std::fs::metadata(&latest).unwrap();
let dated_meta = std::fs::metadata(&snaps[0]).unwrap();
assert_eq!(
(alias.dev(), alias.ino()),
(dated_meta.dev(), dated_meta.ino()),
"-latest must be a hard link to the dated snapshot, not a second copy"
);
}
}
#[tokio::test]
async fn second_startup_does_not_write_another_snapshot() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join(".opencrabs");
std::fs::create_dir_all(&home).unwrap();
let db_path = seeded_db(&home);
with_home_override_async(home.clone(), async {
let db = Database::connect(&db_path).await.unwrap();
db.run_migrations().await.unwrap();
db.run_migrations().await.unwrap();
})
.await;
let snaps = dated_snapshots(&home.join("backups"));
assert_eq!(
snaps.len(),
1,
"a database already at the latest migration must not VACUUM INTO again: {snaps:?}"
);
}
#[tokio::test]
async fn fresh_empty_db_skips_the_snapshot_silently() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join(".opencrabs");
std::fs::create_dir_all(&home).unwrap();
let db_path = home.join("opencrabs.db");
Connection::open(&db_path).unwrap();
with_home_override_async(home.clone(), async {
let db = Database::connect(&db_path).await.unwrap();
db.run_migrations().await.unwrap();
})
.await;
let backups = home.join("backups");
let snaps = if backups.exists() {
dated_snapshots(&backups)
} else {
vec![]
};
assert!(
snaps.is_empty(),
"a brand-new database has nothing to protect: got {snaps:?}"
);
}
#[tokio::test]
async fn in_memory_db_never_snapshots() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join(".opencrabs");
std::fs::create_dir_all(&home).unwrap();
with_home_override_async(home.clone(), async {
let db = Database::connect_in_memory().await.unwrap();
db.run_migrations().await.unwrap();
})
.await;
assert!(
!home.join("backups").exists(),
"in-memory database must not create a backups dir"
);
}
#[tokio::test]
async fn rotation_keeps_seven_dated_copies_and_the_alias() {
let tmp = tempfile::tempdir().unwrap();
let db_path = seeded_db(tmp.path());
let backups = tmp.path().join("backups");
let conn = Connection::open(&db_path).unwrap();
for _ in 0..9 {
snapshot_before_migrations(&conn, &backups)
.unwrap()
.expect("non-empty db must snapshot");
}
let snaps = dated_snapshots(&backups);
assert_eq!(
snaps.len(),
RETENTION,
"retention is the directed 7-day window, got {}",
snaps.len()
);
assert!(
backups.join(LATEST).exists(),
"rotation must never prune the alias"
);
let removed = rotate(&backups).unwrap();
assert_eq!(removed, 0, "already at the cap, nothing to prune");
assert_eq!(dated_snapshots(&backups).len(), RETENTION);
}
#[tokio::test]
async fn migrations_are_refused_when_the_snapshot_cannot_be_taken() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join(".opencrabs");
std::fs::create_dir_all(&home).unwrap();
let db_path = seeded_db(&home);
std::fs::write(home.join("backups"), b"not a directory").unwrap();
let (before, err) = with_home_override_async(home.clone(), async {
let db = Database::connect(&db_path).await.unwrap();
let _warm = db.pool.get().await.unwrap();
let before = std::fs::read(&db_path).unwrap();
let err = db.run_migrations().await.unwrap_err();
(before, err)
})
.await;
let msg = format!("{err:#}");
assert!(
msg.contains("Refusing to run database migrations"),
"message must say it refused, got: {msg}"
);
assert!(
!msg.contains("Failed to run database migrations"),
"the misleading migration wording must not resurface here: {msg}"
);
assert!(
msg.contains("Restore it by copying a snapshot"),
"message must name the recovery action: {msg}"
);
assert!(
msg.contains("brain files and config are untouched"),
"message must scope the blast radius: {msg}"
);
assert_eq!(
std::fs::read(&db_path).unwrap(),
before,
"refused migration must not touch the database file"
);
let names = tables_in(&db_path);
assert!(
!names.contains(&"sessions".to_string()),
"migrations must NOT have run: got {names:?}"
);
let conn = Connection::open_with_flags(&db_path, OpenFlags::SQLITE_OPEN_READ_WRITE).unwrap();
let version: i32 = conn
.query_row("PRAGMA user_version", [], |r| r.get(0))
.unwrap();
assert_eq!(
version, 0,
"a refused migration must not advance user_version"
);
let canary: String = conn
.query_row("SELECT x FROM canary", [], |r| r.get(0))
.unwrap();
assert_eq!(
canary, "nicole",
"the pre-existing row must survive refusal"
);
}
fn corrupt_page_one(path: &Path) -> Vec<u8> {
let mut bytes = std::fs::read(path).unwrap();
bytes[100..200].fill(0);
std::fs::write(path, &bytes).unwrap();
bytes
}
#[tokio::test]
async fn corrupt_image_is_refused_before_any_migration_runs() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join(".opencrabs");
std::fs::create_dir_all(&home).unwrap();
let db_path = seeded_db(&home);
let corrupted = corrupt_page_one(&db_path);
let err = with_home_override_async(home.clone(), async {
let db = Database::connect(&db_path).await.unwrap();
db.run_migrations().await.unwrap_err()
})
.await;
let msg = format!("{err:#}");
assert!(
msg.contains("Refusing to run database migrations"),
"a corrupt image must refuse, not migrate: {msg}"
);
assert!(
msg.contains("the pre-migration integrity check failed"),
"the message must name the stage that found the damage, not blur it \
into a snapshot failure: {msg}"
);
assert!(
msg.contains(&home.join("backups").display().to_string()),
"the message must name the directory the operator restores from: {msg}"
);
assert!(
msg.contains("brain files and config are untouched"),
"the message must bound the blast radius: {msg}"
);
assert!(
!msg.contains("Failed to run database migrations"),
"the misleading wording that hid the rpi5 cause must not resurface: {msg}"
);
assert!(
!msg.contains("post_create hook failed"),
"the check must not depend on the pool: on a torn image post_create \
cannot produce a connection at all: {msg}"
);
assert_eq!(
std::fs::read(&db_path).unwrap(),
corrupted,
"a refused startup must not write a single byte to the image"
);
}
#[tokio::test]
async fn healthy_image_leaves_the_integrity_flag_clear() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join(".opencrabs");
std::fs::create_dir_all(&home).unwrap();
let db_path = seeded_db(&home);
with_home_override_async(home.clone(), async {
let db = Database::connect(&db_path).await.unwrap();
db.run_migrations().await.unwrap();
})
.await;
assert!(
tables_in(&db_path).contains(&"sessions".to_string()),
"migrations must have run on a healthy image"
);
assert!(
!crate::db::db_integrity_failed(),
"a healthy image must not trip the integrity flag"
);
}
#[test]
fn run_migrations_does_not_scan_on_every_open() {
const SRC: &str = include_str!("../db/database.rs");
let preflight = SRC
.find("integrity_preflight(")
.expect("the pre-migration integrity check must exist in run_migrations");
let write = SRC
.find("migrations.to_latest(conn)")
.expect("the migration write must exist");
assert!(
preflight < write,
"the pre-migration integrity check must run BEFORE the write, found \
preflight at {preflight} and the write at {write}"
);
assert!(
!SRC.contains("Run integrity check on startup"),
"run_migrations() must not run an unconditional integrity_check after \
the migrations -- it scans every page on EVERY open (#459, #677)"
);
}
#[test]
fn latest_alias_is_not_a_dated_name() {
assert!(LATEST.starts_with(PREFIX));
assert!(
LATEST
.trim_start_matches(PREFIX)
.chars()
.all(|c| !c.is_ascii_digit()),
"alias must carry no digits so it is distinguishable from dated copies"
);
}
#[test]
fn retention_is_the_directed_window() {
assert_eq!(RETENTION, 7, "owner directive 2026-09-28: rolling 7 days");
}
#[test]
fn retention_bytes_admit_one_large_image_and_not_two() {
let ops_image = 2_440_159_232u64;
assert!(
RETENTION_BYTES >= ops_image,
"one known-good copy of a multi-gigabyte image must fit"
);
assert!(
RETENTION_BYTES < ops_image * 2,
"a second full copy must exceed the budget"
);
}
#[test]
fn byte_budget_keeps_the_newest_by_time_not_by_version() {
let tmp = tempfile::tempdir().unwrap();
for name in [
format!("{PREFIX}55-20260930-010101"),
format!("{PREFIX}60-20260930-020202"),
format!("{PREFIX}59-20260930-030303"),
] {
let file = std::fs::File::create(tmp.path().join(&name)).unwrap();
file.set_len(100).unwrap();
}
std::fs::write(tmp.path().join(LATEST), b"alias").unwrap();
let dated = collect_dated(tmp.path());
assert!(
dated
.last()
.unwrap()
.ends_with("opencrabs.db.pre-migration-59-20260930-030303"),
"03:03 is newer than the version-60 file from 02:02: {dated:?}"
);
let removed = prune_dated(dated, RETENTION, 150);
assert_eq!(removed, 2);
let left = collect_dated(tmp.path());
assert_eq!(left.len(), 1);
assert!(left[0].ends_with("opencrabs.db.pre-migration-59-20260930-030303"));
assert!(
tmp.path().join(LATEST).exists(),
"alias is not a dated copy"
);
}
#[test]
fn thread_id_index_follows_the_migration_list() {
let marker = "ALTER TABLE pending_requests ADD COLUMN channel_thread_id TEXT;";
let pos = MIGRATION_SQL
.iter()
.position(|sql| sql.contains(marker))
.expect("thread-id migration missing");
assert_eq!(thread_id_migration_index(), pos as i64 + 1);
}
#[test]
fn satisfied_heals_do_not_ask_for_a_snapshot() {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE TABLE pending_requests (origin TEXT);
CREATE TABLE notify_queue (id INTEGER);",
)
.unwrap();
assert!(!heals_would_write(&conn, 50).unwrap());
}
#[test]
fn missing_notify_queue_would_write() {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch("CREATE TABLE pending_requests (origin TEXT);")
.unwrap();
assert!(heals_would_write(&conn, 50).unwrap());
}
#[test]
fn missing_origin_column_would_write() {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE TABLE pending_requests (id INTEGER);
CREATE TABLE notify_queue (id INTEGER);",
)
.unwrap();
assert!(heals_would_write(&conn, 50).unwrap());
}