#![allow(dead_code, private_interfaces)]
use std::path::PathBuf;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use tempfile::TempDir;
use tokio::sync::OnceCell;
use umbral::backup::{Dump, dump, dump_to_path, load, load_from_path};
use umbral_core::orm::Post;
#[derive(Debug, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
struct Comment {
id: i64,
body: String,
posted_at: Option<DateTime<Utc>>,
}
static BOOT: OnceCell<()> = OnceCell::const_new();
static TABLES_LOCK: tokio::sync::Mutex<()> = tokio::sync::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 should connect");
umbral::App::builder()
.settings(settings)
.database("default", pool)
.model::<Post>()
.model::<Comment>()
.build()
.expect("App::build should succeed");
let pool = umbral::db::pool();
sqlx::query(
"CREATE TABLE IF NOT EXISTS post (\
id INTEGER PRIMARY KEY AUTOINCREMENT,\
title TEXT NOT NULL,\
body TEXT NOT NULL,\
published_at TEXT\
)",
)
.execute(&pool)
.await
.expect("create post");
sqlx::query(
"CREATE TABLE IF NOT EXISTS comment (\
id INTEGER PRIMARY KEY AUTOINCREMENT,\
body TEXT NOT NULL,\
posted_at TEXT\
)",
)
.execute(&pool)
.await
.expect("create comment");
})
.await;
}
#[tokio::test]
async fn dump_walks_every_registered_model() {
boot().await;
let _guard = TABLES_LOCK.lock().await;
let pool = umbral::db::pool();
sqlx::query("DELETE FROM post")
.execute(&pool)
.await
.expect("clean post");
sqlx::query("DELETE FROM comment")
.execute(&pool)
.await
.expect("clean comment");
sqlx::query("INSERT INTO post (title, body, published_at) VALUES (?, ?, ?)")
.bind("hello")
.bind("first post body")
.bind("2026-05-31T12:00:00Z")
.execute(&pool)
.await
.expect("seed post 1");
sqlx::query("INSERT INTO post (title, body, published_at) VALUES (?, ?, ?)")
.bind("draft")
.bind("second post body, unpublished")
.bind(None::<String>)
.execute(&pool)
.await
.expect("seed post 2");
sqlx::query("INSERT INTO comment (body, posted_at) VALUES (?, ?)")
.bind("nice post")
.bind("2026-05-31T12:30:00Z")
.execute(&pool)
.await
.expect("seed comment");
let d: Dump = dump().await.expect("dump should succeed");
assert_eq!(d.umbral_dump_version, "1");
assert!(!d.exported_at.is_empty());
let tables: Vec<&str> = d.models.iter().map(|m| m.table.as_str()).collect();
assert!(
tables.contains(&"post"),
"dump should include `post`; got {tables:?}"
);
assert!(
tables.contains(&"comment"),
"dump should include `comment`; got {tables:?}"
);
let post = d.models.iter().find(|m| m.table == "post").unwrap();
assert!(
post.rows.len() >= 2,
"expected at least 2 post rows; got {}",
post.rows.len()
);
let has_published: usize = post
.rows
.iter()
.filter(|r| r.get("published_at").is_some_and(|v| !v.is_null()))
.count();
let has_null: usize = post
.rows
.iter()
.filter(|r| r.get("published_at").is_some_and(|v| v.is_null()))
.count();
assert!(has_published >= 1 && has_null >= 1);
}
#[tokio::test]
async fn round_trip_through_disk_preserves_rows() {
boot().await;
let _guard = TABLES_LOCK.lock().await;
let pool = umbral::db::pool();
sqlx::query("DELETE FROM post")
.execute(&pool)
.await
.expect("clean post");
sqlx::query("DELETE FROM comment")
.execute(&pool)
.await
.expect("clean comment");
sqlx::query("INSERT INTO comment (body, posted_at) VALUES (?, ?)")
.bind("survives the round trip")
.bind("2026-05-31T13:00:00Z")
.execute(&pool)
.await
.expect("seed");
let tmp: TempDir = tempfile::tempdir().expect("tempdir");
let path: PathBuf = tmp.path().join("dump.json");
dump_to_path(&path).await.expect("dump_to_path");
assert!(path.exists(), "dump file should exist at {path:?}");
sqlx::query("DELETE FROM comment")
.execute(&pool)
.await
.expect("wipe comment");
sqlx::query("DELETE FROM post")
.execute(&pool)
.await
.expect("wipe post");
let count_after_wipe: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM comment")
.fetch_one(&pool)
.await
.expect("count");
assert_eq!(count_after_wipe.0, 0);
let report = load_from_path(&path).await.expect("load_from_path");
assert!(
report.rows_loaded >= 1,
"load should report rows; got {}",
report.rows_loaded
);
assert!(
report.tables_loaded.contains(&"comment".to_string()),
"report should list `comment`; got {:?}",
report.tables_loaded
);
let count_after_load: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM comment")
.fetch_one(&pool)
.await
.expect("count");
assert!(
count_after_load.0 >= 1,
"load should have written rows back; got {}",
count_after_load.0
);
let survived: Option<(String, Option<String>)> = sqlx::query_as(
"SELECT body, posted_at FROM comment WHERE body = 'survives the round trip'",
)
.fetch_optional(&pool)
.await
.expect("select survivor");
let (body, posted_at) = survived.expect("the seeded comment should round-trip");
assert_eq!(body, "survives the round trip");
assert_eq!(
posted_at.as_deref(),
Some("2026-05-31T13:00:00+00:00"),
"RFC-3339 timestamp survives the round trip (timezone normalised)"
);
}
#[tokio::test]
async fn load_rejects_unsupported_dump_version() {
boot().await;
let bad = Dump {
umbral_dump_version: "99".to_string(),
exported_at: "2026-05-31T00:00:00Z".to_string(),
models: Vec::new(),
};
let err = load(&bad).await.expect_err("load should reject version 99");
let msg = err.to_string();
assert!(
msg.contains("99") && msg.contains("not supported"),
"diagnostic should mention the offending version and the unsupported case; got {msg}"
);
}
#[tokio::test]
async fn load_skips_unknown_tables() {
boot().await;
let dump = Dump {
umbral_dump_version: "1".to_string(),
exported_at: "2026-05-31T00:00:00Z".to_string(),
models: vec![umbral::backup::ModelDump {
table: "table_that_does_not_exist".to_string(),
rows: vec![],
}],
};
let report = load(&dump)
.await
.expect("load should not fail on unknown table");
assert_eq!(report.tables_loaded.len(), 0);
assert!(
report
.skipped_tables
.contains(&"table_that_does_not_exist".to_string()),
"the unknown table should appear in skipped_tables; got {:?}",
report.skipped_tables
);
}
#[allow(dead_code)]
fn _unused_pool_marker(_: SqlitePool) {}