use std::path::PathBuf;
use slave_framework::database::{Db, is_already_exists, is_transaction_conflict, migrations};
use slave_framework::surrealdb::types::SurrealValue;
use slave_framework::testing::TestDatabase;
#[derive(Debug, SurrealValue)]
struct Note {
id: i64,
title: String,
}
fn migrations_dir(test: &str, files: &[(&str, &str)]) -> PathBuf {
let dir = std::env::temp_dir().join(format!("slave-migrations-{}-{test}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
for (name, source) in files {
std::fs::write(dir.join(name), source).unwrap();
}
dir
}
const NOTES: &str = "
DEFINE SEQUENCE note_id START 1;
DEFINE TABLE note SCHEMAFULL;
DEFINE FIELD title ON note TYPE string;
DEFINE INDEX note_title ON note FIELDS title UNIQUE;
";
async fn create_note(db: &Db, title: &str) -> Result<Note, slave_framework::surrealdb::Error> {
let note: Option<Note> = db
.query(
"CREATE ONLY type::record('note', sequence::nextval('note_id')) SET title = $title
RETURN *, record::id(id) AS id",
)
.bind(("title", title.to_owned()))
.await?
.check()?
.take(0)?;
Ok(note.expect("created note"))
}
#[tokio::test]
async fn migrations_apply_once_in_order() {
let dir = migrations_dir(
"order",
&[
("20260101_000000_notes.surql", NOTES),
(
"20260102_000000_seed.surql",
"CREATE note:100 SET title = 'seeded';",
),
("README.md", "not a migration"),
],
);
let test_db = TestDatabase::start();
let db = test_db.connect().await;
let applied = migrations::apply(&db, &dir).await.unwrap();
assert_eq!(applied, ["20260101_000000_notes", "20260102_000000_seed"]);
assert!(migrations::apply(&db, &dir).await.unwrap().is_empty());
let status = migrations::status(&db, &dir).await.unwrap();
assert!(status.iter().all(|(_, applied)| *applied));
}
#[tokio::test]
async fn a_failing_migration_changes_nothing() {
let dir = migrations_dir(
"failing",
&[
("20260101_000000_notes.surql", NOTES),
(
"20260102_000000_broken.surql",
"CREATE note:1 SET title = 'a'; CREATE note:2 SET title = 1;",
),
],
);
let test_db = TestDatabase::start();
let db = test_db.connect().await;
assert!(migrations::apply(&db, &dir).await.is_err());
assert_eq!(
migrations::applied(&db).await.unwrap(),
["20260101_000000_notes"]
);
let titles: Vec<String> = db
.query("SELECT VALUE title FROM note")
.await
.unwrap()
.take(0)
.unwrap();
assert!(titles.is_empty(), "{titles:?}");
}
#[tokio::test]
async fn reset_drops_everything() {
let dir = migrations_dir("reset", &[("20260101_000000_notes.surql", NOTES)]);
let test_db = TestDatabase::start();
let db = test_db.migrate(&dir).await;
create_note(&db, "gone").await.unwrap();
migrations::reset(&db).await.unwrap();
assert!(migrations::applied(&db).await.unwrap().is_empty());
migrations::apply(&db, &dir).await.unwrap();
let note = create_note(&db, "fresh").await.unwrap();
assert_eq!(note.id, 1, "the sequence starts over");
}
#[tokio::test]
async fn unique_indexes_reject_duplicates() {
let dir = migrations_dir("unique", &[("20260101_000000_notes.surql", NOTES)]);
let test_db = TestDatabase::start();
let db = test_db.migrate(&dir).await;
let first = create_note(&db, "first").await.unwrap();
assert_eq!(first.id, 1);
let duplicate = create_note(&db, "first").await.unwrap_err();
assert!(is_already_exists(&duplicate), "{duplicate:?}");
}
#[tokio::test]
async fn concurrent_writers_all_succeed() {
let dir = migrations_dir("concurrent", &[("20260101_000000_notes.surql", NOTES)]);
let test_db = TestDatabase::start();
let db = test_db.migrate(&dir).await;
let tasks: Vec<_> = (0..20)
.map(|n| {
let db = db.clone();
tokio::spawn(async move { create_note(&db, &format!("note {n}")).await })
})
.collect();
let mut ids = Vec::new();
for task in tasks {
ids.push(task.await.unwrap().unwrap().id);
}
ids.sort();
ids.dedup();
assert_eq!(ids.len(), 20, "ids are unique");
}
#[tokio::test]
async fn conflicting_transactions_are_reported_as_conflicts() {
let dir = migrations_dir("conflict", &[("20260101_000000_notes.surql", NOTES)]);
let test_db = TestDatabase::start();
let db = test_db.migrate(&dir).await;
db.query("CREATE note:1 SET title = 'shared'")
.await
.unwrap()
.check()
.unwrap();
let first = db.clone().begin().await.unwrap();
let second = db.clone().begin().await.unwrap();
for (tx, title) in [(&first, "one"), (&second, "two")] {
tx.query("UPDATE note:1 SET title = $title")
.bind(("title", title))
.await
.unwrap()
.check()
.unwrap();
}
first.commit().await.unwrap();
let err = second.commit().await.unwrap_err();
assert!(is_transaction_conflict(&err), "{err:?}");
}