#[cfg(test)]
mod human_tests {
use std::time::Duration;
use crate::human::HumanDuration;
use crate::human::HumanSize;
#[test]
fn human_duration_parses_valid_inputs() {
let cases: &[(&str, Duration)] = &[
("30s", Duration::from_secs(30)),
("250ms", Duration::from_millis(250)),
("2h", Duration::from_secs(7200)),
("1d", Duration::from_secs(86400)),
];
for (input, expected) in cases {
assert_eq!(
input.parse::<HumanDuration>().unwrap().as_duration(),
*expected,
"input {input:?}"
);
}
}
#[test]
fn human_duration_rejects_invalid_inputs() {
for input in ["1.5ms", "30x", "-1s"] {
assert!(
input.parse::<HumanDuration>().is_err(),
"input {input:?} should be rejected"
);
}
}
#[test]
fn human_duration_from_toml_values() {
let raw: toml::Value = toml::Value::Integer(30000);
let dur: HumanDuration = raw.try_into().unwrap();
assert_eq!(dur.as_duration(), Duration::from_millis(30000));
let raw: toml::Value = toml::Value::String("5m".into());
let dur: HumanDuration = raw.try_into().unwrap();
assert_eq!(dur.as_duration(), Duration::from_secs(300));
}
#[test]
fn human_duration_display_roundtrip() {
let dur = HumanDuration::sec(60);
assert_eq!(dur.to_string(), "1m");
assert_eq!(
dur.to_string()
.parse::<HumanDuration>()
.unwrap()
.as_duration(),
Duration::from_secs(60)
);
}
#[test]
fn human_size_parses_valid_inputs() {
let cases: &[(&str, u64)] = &[
("100B", 100),
("1KiB", 1024),
("1MiB", 1024 * 1024),
("1GiB", 1024 * 1024 * 1024),
("256MiB", 256 * 1024 * 1024),
("1.5GiB", (1.5 * 1024.0 * 1024.0 * 1024.0) as u64),
];
for (input, expected) in cases {
assert_eq!(
input.parse::<HumanSize>().unwrap().as_bytes(),
*expected,
"input {input:?}"
);
}
}
#[test]
fn human_size_rejects_invalid_inputs() {
for input in ["30x", "-1MiB", "99999999999999999999GiB"] {
assert!(
input.parse::<HumanSize>().is_err(),
"input {input:?} should be rejected"
);
}
}
#[test]
fn human_size_from_toml_values() {
let raw: toml::Value = toml::Value::Integer(67108864);
let size: HumanSize = raw.try_into().unwrap();
assert_eq!(size.as_bytes(), 67108864);
let raw: toml::Value = toml::Value::String("128MiB".into());
let size: HumanSize = raw.try_into().unwrap();
assert_eq!(size.as_bytes(), 128 * 1024 * 1024);
}
#[test]
fn human_size_display_roundtrip() {
let size = HumanSize::gib(1);
assert_eq!(size.to_string(), "1GiB");
assert_eq!(
size.to_string().parse::<HumanSize>().unwrap().as_bytes(),
1024 * 1024 * 1024
);
}
}
#[cfg(test)]
mod config_tests {
use crate::config::UrsulaConfig;
#[test]
fn deserialize_minimal_config() {
let toml = r#"
[server]
listen = "0.0.0.0:4437"
[runtime]
core_count = 16
[raft]
group_count = 256
[raft.wal]
backend = "disk"
path = "/var/lib/ursula"
[[raft.peers]]
node_id = 1
url = "http://10.0.0.1:4437"
[storage.cold]
backend = "s3"
flush_interval = "30s"
flush_size = "64MiB"
[storage.cold.s3]
bucket = "my-bucket"
region = "us-east-1"
[storage.snapshot]
backend = "s3"
"#;
let config: UrsulaConfig = toml::from_str(toml).expect("valid config");
assert_eq!(config.server.listen, "0.0.0.0:4437");
assert_eq!(config.runtime.core_count, 16);
assert_eq!(config.raft.node_id, 0); assert_eq!(config.raft.group_count, 256);
use crate::config::ColdBackend;
use crate::config::RaftSnapshotBackend;
use crate::config::WalBackend;
assert_eq!(config.raft.wal.backend, WalBackend::Disk);
assert_eq!(config.storage.cold.backend, ColdBackend::S3);
assert_eq!(config.storage.snapshot.backend, RaftSnapshotBackend::S3);
assert_eq!(config.raft.snapshot_build_max_concurrency, 1);
assert_eq!(
config.storage.cold.s3.as_ref().unwrap().bucket,
Some("my-bucket".into())
);
}
#[test]
fn raft_snapshot_build_concurrency_is_configurable() {
let config: UrsulaConfig = toml::from_str(
r#"
[raft]
snapshot_build_max_concurrency = 2
"#,
)
.expect("snapshot_build_max_concurrency parses");
assert_eq!(config.raft.snapshot_build_max_concurrency, 2);
}
#[test]
fn raft_snapshot_log_retention_is_bounded_and_configurable() {
let default = UrsulaConfig::default();
assert_eq!(default.raft.snapshot_logs_since_last, 5_000);
assert_eq!(default.raft.snapshot_pressure_unpurged_logs, 65_536);
assert_eq!(default.raft.snapshot_pressure_max_groups_per_tick, 16);
assert_eq!(default.raft.max_in_snapshot_log_to_keep, 64);
let config: UrsulaConfig = toml::from_str(
r#"
[raft]
snapshot_logs_since_last = 20000
snapshot_pressure_unpurged_logs = 131072
snapshot_pressure_max_groups_per_tick = 32
max_in_snapshot_log_to_keep = 128
"#,
)
.expect("max_in_snapshot_log_to_keep parses");
assert_eq!(config.raft.snapshot_logs_since_last, 20_000);
assert_eq!(config.raft.snapshot_pressure_unpurged_logs, 131_072);
assert_eq!(config.raft.snapshot_pressure_max_groups_per_tick, 32);
assert_eq!(config.raft.max_in_snapshot_log_to_keep, 128);
}
#[test]
fn snapshot_drive_interval_is_optional_and_zero_is_explicit_disable() {
use crate::human::HumanDuration;
let omitted: UrsulaConfig = toml::from_str(
r#"
[storage.snapshot]
backend = "s3"
"#,
)
.expect("omitted drive_interval parses");
assert_eq!(omitted.storage.snapshot.drive_interval, None);
let disabled: UrsulaConfig = toml::from_str(
r#"
[storage.snapshot]
backend = "s3"
drive_interval = "0s"
"#,
)
.expect("explicit zero drive_interval parses");
assert_eq!(
disabled.storage.snapshot.drive_interval,
Some(HumanDuration::milli(0))
);
let explicit: UrsulaConfig = toml::from_str(
r#"
[storage.snapshot]
backend = "s3"
drive_interval = "45s"
"#,
)
.expect("explicit non-zero drive_interval parses");
assert_eq!(
explicit.storage.snapshot.drive_interval,
Some(HumanDuration::sec(45))
);
}
#[test]
fn cold_cache_config_has_omitted_zero_and_custom_states() {
use crate::human::HumanSize;
let omitted: UrsulaConfig = toml::from_str(
r#"
[storage.cold]
backend = "memory"
"#,
)
.expect("omitted cache parses");
assert_eq!(omitted.storage.cold.cache, None);
let disabled: UrsulaConfig = toml::from_str(
r#"
[storage.cold]
backend = "memory"
[storage.cold.cache]
max_size = "0B"
"#,
)
.expect("zero cache parses");
assert_eq!(
disabled.storage.cold.cache.unwrap().max_size,
HumanSize::bytes(0)
);
let custom: UrsulaConfig = toml::from_str(
r#"
[storage.cold]
backend = "memory"
[storage.cold.cache]
max_size = "12MiB"
block_size = "2MiB"
readahead_blocks = 7
"#,
)
.expect("custom cache parses");
let cache = custom.storage.cold.cache.unwrap();
assert_eq!(cache.max_size, HumanSize::mib(12));
assert_eq!(cache.block_size, HumanSize::mib(2));
assert_eq!(cache.readahead_blocks, 7);
}
}
#[cfg(test)]
mod load_tests {
use std::io::Write;
use crate::config::WalBackend;
use crate::load::load_config;
use crate::preset::Preset;
fn temp_config(suffix: &str, contents: &str) -> tempfile::NamedTempFile {
let mut tmp = tempfile::NamedTempFile::with_suffix(suffix).unwrap();
write!(tmp, "{contents}").unwrap();
tmp
}
fn available_cores() -> usize {
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(4)
}
#[test]
fn load_minimal_config_without_preset() {
let tmp = temp_config(
".toml",
r#"
[server]
listen = "127.0.0.1:4437"
[runtime]
core_count = 4
[raft]
group_count = 16
[raft.wal]
backend = "memory"
"#,
);
let config = load_config(Some(tmp.path()), None, Some(1)).unwrap();
assert_eq!(config.runtime.core_count, 4);
assert_eq!(config.raft.node_id, 1);
assert_eq!(config.raft.wal.backend, WalBackend::Memory);
}
#[test]
fn preset_tiny_overrides_defaults() {
let tmp = temp_config(
".toml",
r#"
[server]
listen = "127.0.0.1:4437"
"#,
);
let config = load_config(Some(tmp.path()), Some(Preset::Tiny), Some(1)).unwrap();
assert_eq!(config.runtime.core_count, available_cores());
assert_eq!(config.raft.group_count, 64); assert_eq!(config.raft.wal.backend, WalBackend::Memory); }
#[test]
fn user_config_overrides_preset() {
let tmp = temp_config(
".toml",
r#"
[runtime]
core_count = 2
"#,
);
let config = load_config(Some(tmp.path()), Some(Preset::Tiny), Some(1)).unwrap();
assert_eq!(config.runtime.core_count, 2); assert_eq!(config.raft.group_count, 64); }
#[test]
fn validation_rejects_disk_without_path() {
let tmp = temp_config(
".toml",
r#"
[raft.wal]
backend = "disk"
"#,
);
let err = load_config(Some(tmp.path()), None, Some(1)).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("raft.wal.path"),
"error should mention raft.wal.path: {msg}"
);
}
#[test]
fn validation_rejects_volatile_multi_peer_without_opt_in() {
let tmp = temp_config(
".toml",
r#"
[raft.wal]
backend = "memory"
[[raft.peers]]
node_id = 1
url = "http://127.0.0.1:4437"
[[raft.peers]]
node_id = 2
url = "http://127.0.0.1:4438"
"#,
);
let err = load_config(Some(tmp.path()), None, Some(1)).unwrap_err();
assert!(
err.to_string().contains("allow_volatile_multi_peer"),
"error should name the explicit opt-in: {err}"
);
}
#[test]
fn volatile_multi_peer_explicit_opt_in_is_accepted() {
let tmp = temp_config(
".toml",
r#"
[raft.wal]
backend = "memory"
allow_volatile_multi_peer = true
[[raft.peers]]
node_id = 1
url = "http://127.0.0.1:4437"
[[raft.peers]]
node_id = 2
url = "http://127.0.0.1:4438"
"#,
);
let config = load_config(Some(tmp.path()), None, Some(1)).expect("explicit opt-in");
assert!(config.raft.wal.allow_volatile_multi_peer);
}
#[test]
fn validation_rejects_disk_pressure_resume_at_or_below_minimum() {
let tmp = temp_config(
".toml",
r#"
[raft.wal]
backend = "disk"
path = "/tmp/ursula-wal"
min_available_size = "1GiB"
resume_available_size = "512MiB"
"#,
);
let err = load_config(Some(tmp.path()), None, Some(1)).unwrap_err();
assert!(
err.to_string().contains("resume_available_size"),
"error should name the invalid watermark: {err}"
);
}
#[test]
fn validation_rejects_s3_without_bucket() {
let tmp = temp_config(
".toml",
r#"
[storage.cold]
backend = "s3"
"#,
);
let err = load_config(Some(tmp.path()), None, Some(1)).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("bucket"), "error should mention bucket: {msg}");
}
#[test]
fn nested_table_merge() {
let tmp = temp_config(
".toml",
r#"
[storage.cold.cache]
max_size = "128MiB"
"#,
);
let config = load_config(Some(tmp.path()), Some(Preset::Tiny), Some(1)).unwrap();
assert_eq!(
config
.storage
.cold
.cache
.as_ref()
.unwrap()
.max_size
.as_bytes(),
128 * 1024 * 1024
);
assert_eq!(config.storage.cold.flush_size.as_bytes(), 4 * 1024 * 1024);
}
#[test]
fn array_replacement_not_append() {
let tmp = temp_config(
".toml",
r#"
[raft.wal]
allow_volatile_multi_peer = true
[[raft.peers]]
node_id = 1
url = "http://10.0.0.1:4437"
[[raft.peers]]
node_id = 2
url = "http://10.0.0.2:4437"
"#,
);
let config = load_config(Some(tmp.path()), None, Some(1)).unwrap();
assert_eq!(config.raft.peers.len(), 2);
assert_eq!(config.raft.peers[0].node_id, 1);
assert_eq!(config.raft.peers[1].node_id, 2);
}
#[test]
fn node_id_from_cli_overrides_file() {
let tmp = temp_config(
".toml",
r#"
[raft]
node_id = 1
"#,
);
let config = load_config(Some(tmp.path()), None, Some(42)).unwrap();
assert_eq!(config.raft.node_id, 42);
}
#[test]
fn validation_rejects_missing_node_id() {
let tmp = temp_config(
".toml",
r#"
[server]
listen = "127.0.0.1:4437"
"#,
);
let err = load_config(Some(tmp.path()), None, None).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("node_id") && msg.contains("--node-id"),
"error should mention --node-id: {msg}"
);
}
#[test]
fn preset_roundtrip_equality() {
use crate::UrsulaConfig;
use crate::preset::Preset;
let preset = Preset::Standard;
let original = UrsulaConfig::from(preset);
let value = toml::Value::try_from(&original).expect("serialise");
let table = value.as_table().cloned().expect("is table");
let mut merged = table.clone();
crate::load::merge_tables_for_test(&mut merged, toml::Table::new());
let restored: UrsulaConfig = merged.try_into().expect("deserialise");
assert_eq!(original.server.listen, restored.server.listen);
assert_eq!(original.runtime.core_count, restored.runtime.core_count);
assert_eq!(original.raft.group_count, restored.raft.group_count);
assert_eq!(
original.raft.rejoin_probe.as_duration(),
restored.raft.rejoin_probe.as_duration()
);
assert_eq!(
original.storage.cold.flush_size.as_bytes(),
restored.storage.cold.flush_size.as_bytes()
);
assert_eq!(
original
.storage
.cold
.cache
.as_ref()
.unwrap()
.max_size
.as_bytes(),
restored
.storage
.cold
.cache
.as_ref()
.unwrap()
.max_size
.as_bytes()
);
}
#[test]
fn preset_alone_without_config_file() {
let config = load_config(None, Some(Preset::Tiny), Some(1)).unwrap();
assert_eq!(config.runtime.core_count, available_cores());
assert_eq!(config.raft.group_count, 64);
assert_eq!(config.raft.node_id, 1);
assert_eq!(
config
.storage
.cold
.cache
.as_ref()
.unwrap()
.max_size
.as_bytes(),
64 * 1024 * 1024
);
}
#[test]
fn presets_match_legacy_profiles() {
let mib = |n: u64| n * 1024 * 1024;
let cases = [
(
Preset::Tiny,
"tiny",
(Some(8_192), 64, mib(8), mib(64), mib(4), 2, mib(8)),
),
(
Preset::Standard,
"standard",
(Some(65_536), 256, mib(64), mib(256), mib(8), 4, mib(64)),
),
];
for (preset, name, expected) in cases {
let config = load_config(None, Some(preset), Some(1)).unwrap();
assert_eq!(
config.runtime.core_count,
available_cores(),
"preset {name}: core_count"
);
let actual = (
config.runtime.live_read_max_waiters_per_core,
config.raft.group_count,
config
.raft
.max_uncommitted_size_per_group
.unwrap()
.as_bytes(),
config.server.http_inflight_body_size.as_bytes(),
config.storage.cold.flush_size.as_bytes(),
config.storage.cold.flush_max_concurrency,
config
.storage
.cold
.max_hot_size_per_group
.unwrap()
.as_bytes(),
);
assert_eq!(actual, expected, "preset {name}");
}
}
#[test]
fn s3_server_side_encryption_defaults_to_aes256() {
use crate::config::S3ServerSideEncryption;
use crate::config::UrsulaConfig;
let config: UrsulaConfig = toml::from_str(
r#"
[storage.cold]
backend = "s3"
[storage.cold.s3]
bucket = "my-bucket"
"#,
)
.expect("valid config");
let s3 = config.storage.cold.s3.expect("s3 config");
assert_eq!(s3.server_side_encryption, S3ServerSideEncryption::Aes256);
assert_eq!(s3.kms_key_id, None);
}
#[test]
fn s3_server_side_encryption_parses_all_modes() {
use crate::config::S3ServerSideEncryption;
use crate::config::UrsulaConfig;
for (value, expected) in [
("aes256", S3ServerSideEncryption::Aes256),
("aws-kms", S3ServerSideEncryption::AwsKms),
("none", S3ServerSideEncryption::None),
] {
let toml = format!(
r#"
[storage.cold]
backend = "s3"
[storage.cold.s3]
bucket = "my-bucket"
server_side_encryption = "{value}"
"#
);
let config: UrsulaConfig = toml::from_str(&toml).expect("valid config");
let s3 = config.storage.cold.s3.expect("s3 config");
assert_eq!(s3.server_side_encryption, expected, "mode {value}");
}
}
#[test]
fn s3_kms_key_id_round_trips() {
use crate::config::S3ServerSideEncryption;
use crate::config::UrsulaConfig;
let config: UrsulaConfig = toml::from_str(
r#"
[storage.cold]
backend = "s3"
[storage.cold.s3]
bucket = "my-bucket"
server_side_encryption = "aws-kms"
kms_key_id = "arn:aws:kms:us-east-1:111122223333:key/test"
"#,
)
.expect("valid config");
let s3 = config.storage.cold.s3.expect("s3 config");
assert_eq!(s3.server_side_encryption, S3ServerSideEncryption::AwsKms);
assert_eq!(
s3.kms_key_id.as_deref(),
Some("arn:aws:kms:us-east-1:111122223333:key/test")
);
}
}