use std::error::Error;
use std::path::Path;
use std::time::Duration;
use haematite::db::DatabaseConfig;
use haematite::store::NodeCacheBudget;
use serde_json::json;
use super::{branch_rename, parse};
use crate::serve::protocol::{Change, CollectionWrite, Fork, Mutation, Selection, Version};
use crate::serve::storage::{Captured, Storage};
type TestResult = Result<(), Box<dyn Error>>;
const WAIT: Duration = Duration::from_secs(5);
fn args(words: &[&str]) -> impl Iterator<Item = String> {
words
.iter()
.map(|word| (*word).to_owned())
.collect::<Vec<_>>()
.into_iter()
}
fn head(storage: &Storage, branch: &str) -> Result<Option<Version>, Box<dyn Error>> {
Ok(storage.catalogue().head(branch)?)
}
fn captured(storage: &Storage, branch: &str) -> Result<Captured, Box<dyn Error>> {
let version = head(storage, branch)?.ok_or("branch absent")?;
Ok(storage.capture(&Selection {
branch: branch.to_owned(),
version: Some(version),
})?)
}
fn roots(captured: &Captured) -> Vec<String> {
captured
.shards
.iter()
.map(|shard| format!("{}:{}", shard.shard_id, shard.head))
.collect()
}
fn store(directory: &Path) -> Result<String, Box<dyn Error>> {
let data = directory.join("database");
let mut storage = Storage::create(
DatabaseConfig {
data_dir: data.clone(),
shard_count: 2,
distributed: None,
executor_threads: Some(1),
node_cache_budget: Some(NodeCacheBudget::Unlimited),
retention: haematite::Retention::KeepAll,
},
NodeCacheBudget::Unlimited,
WAIT,
)?;
let main = head(&storage, "main")?.ok_or("no main")?;
storage.mutate(&Mutation {
request_id: "fork".to_owned(),
branch: "main".to_owned(),
expected: main,
change: Change::Fork(Fork {
name: "old-name".to_owned(),
from: main,
}),
})?;
let old = head(&storage, "old-name")?.ok_or("no fork")?;
storage.mutate(&Mutation {
request_id: "wards".to_owned(),
branch: "old-name".to_owned(),
expected: old,
change: Change::Collection(CollectionWrite {
collection: "wards".to_owned(),
schema: None,
}),
})?;
drop(storage);
let config = directory.join("serve.json");
std::fs::write(
&config,
serde_json::to_vec(&json!({
"data_dir": data,
"socket": directory.join("serve.sock"),
"branch_cache_bytes_per_shard": 1_048_576,
"max_frame": 65_536,
"max_clients": 1,
"max_page": 8,
"timeout_ms": 1000
}))?,
)?;
Ok(config.to_str().ok_or("path is not UTF-8")?.to_owned())
}
fn reopened(directory: &Path) -> Result<Storage, Box<dyn Error>> {
Ok(Storage::open(
&directory.join("database"),
NodeCacheBudget::Unlimited,
WAIT,
)?)
}
#[test]
fn a_rename_moves_the_head_and_discards_the_old_name() -> TestResult {
let directory = tempfile::tempdir()?;
let config = store(directory.path())?;
let before = roots(&captured(&reopened(directory.path())?, "old-name")?);
let rename = [
"--config", &config, "--from", "old-name", "--to", "new-name", "--apply",
];
branch_rename(args(&rename))?;
let storage = reopened(directory.path())?;
assert_eq!(
head(&storage, "old-name")?,
None,
"the old name is discarded"
);
let renamed = captured(&storage, "new-name")?;
let anchors: Vec<String> = renamed
.shards
.iter()
.map(|shard| format!("{}:{}", shard.shard_id, shard.fork_anchor))
.collect();
assert_eq!(anchors, before, "the new name was forked from the old head");
assert_ne!(
roots(&captured(&storage, "main")?),
before,
"a control: main differs"
);
drop(storage);
let again = branch_rename(args(&rename));
assert!(
again
.as_ref()
.is_err_and(|error| error.starts_with("branch_absent")),
"{again:?}"
);
Ok(())
}
#[test]
fn a_check_changes_nothing() -> TestResult {
let directory = tempfile::tempdir()?;
let config = store(directory.path())?;
let storage = reopened(directory.path())?;
let before = (
head(&storage, "old-name")?,
roots(&captured(&storage, "old-name")?),
);
drop(storage);
branch_rename(args(&[
"--config", &config, "--from", "old-name", "--to", "new-name", "--check",
]))?;
let storage = reopened(directory.path())?;
assert_eq!(
(
head(&storage, "old-name")?,
roots(&captured(&storage, "old-name")?)
),
before
);
assert_eq!(head(&storage, "new-name")?, None);
Ok(())
}
#[test]
fn an_unplaceable_absent_or_taken_name_is_refused_and_nothing_moves() -> TestResult {
let directory = tempfile::tempdir()?;
let config = store(directory.path())?;
for (from, to, expected) in [
("old-name", "import/x", "branch_name_unplaceable"),
("nowhere", "new-name", "branch_absent"),
("old-name", "main", "branch_exists"),
] {
let refused = branch_rename(args(&[
"--config", &config, "--from", from, "--to", to, "--apply",
]));
assert!(
refused
.as_ref()
.is_err_and(|error| error.starts_with(expected)),
"{from} to {to}: {refused:?}"
);
}
let storage = reopened(directory.path())?;
assert!(head(&storage, "old-name")?.is_some());
assert_eq!(head(&storage, "import/x")?, None);
Ok(())
}
#[test]
fn malformed_arguments_are_refused() {
for words in [
&["--config", "c", "--from", "a", "--to", "b"][..],
&["--config", "c", "--from", "a", "--to", "a", "--check"][..],
&[
"--config", "c", "--from", "a", "--from", "b", "--to", "c", "--check",
][..],
&[
"--config", "c", "--from", "a", "--to", "b", "--check", "--apply",
][..],
&[
"--config", "c", "--from", "a", "--to", "b", "--check", "--json",
][..],
&["--config", "c", "--from"][..],
] {
assert!(parse(args(words)).is_err(), "{words:?}");
}
}