481 lines
14 KiB
Rust
481 lines
14 KiB
Rust
//! Integration tests for the tidalctl binary.
|
|
//!
|
|
//! Tests run the actual binary as a subprocess via `std::process::Command`.
|
|
|
|
#![forbid(unsafe_code)]
|
|
#![allow(clippy::unwrap_used)]
|
|
|
|
use std::process::Command;
|
|
|
|
use tidaldb::db::temp::TempTidalHome;
|
|
use tidaldb::wal::{SignalEvent, WalConfig, WalHandle};
|
|
|
|
fn tidalctl_bin() -> Command {
|
|
Command::new(env!("CARGO_BIN_EXE_tidalctl"))
|
|
}
|
|
|
|
/// Create a `TempTidalHome` with WAL segments and a checkpoint.
|
|
/// Returns the home so it stays alive (not dropped) for the test.
|
|
fn home_with_wal_data() -> TempTidalHome {
|
|
let home = TempTidalHome::new().expect("create temp home");
|
|
let paths = home.paths();
|
|
paths.ensure_all().expect("create subdirs");
|
|
|
|
let config = WalConfig {
|
|
dir: home.path().to_path_buf(),
|
|
..WalConfig::default()
|
|
};
|
|
|
|
let (handle, _replayed, _session_events) = WalHandle::open(config).expect("open WAL");
|
|
|
|
for i in 1..=5 {
|
|
let event = SignalEvent {
|
|
entity_id: i,
|
|
signal_type: 1,
|
|
weight: 1.0,
|
|
timestamp_nanos: i * 1_000_000_000,
|
|
};
|
|
let _seq = handle.append(event).expect("append event");
|
|
}
|
|
|
|
handle.checkpoint(3).expect("checkpoint");
|
|
handle.shutdown().expect("shutdown WAL");
|
|
|
|
home
|
|
}
|
|
|
|
#[test]
|
|
fn status_with_wal_segments_exits_0() {
|
|
let home = home_with_wal_data();
|
|
|
|
let output = tidalctl_bin()
|
|
.args(["status", "--path", home.path().to_str().unwrap()])
|
|
.output()
|
|
.expect("run tidalctl");
|
|
|
|
assert!(
|
|
output.status.success(),
|
|
"exit code: {}, stderr: {}",
|
|
output.status,
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
|
|
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
|
let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
|
|
|
|
assert_eq!(json["status"], "ok");
|
|
assert!(
|
|
json["wal"]["segments"].as_u64().unwrap_or(0) > 0,
|
|
"should have segments: {stdout}"
|
|
);
|
|
assert!(
|
|
json["wal"]["checkpoint_seq"].as_u64().unwrap_or(0) > 0,
|
|
"should have checkpoint: {stdout}"
|
|
);
|
|
assert!(json["version"].is_string());
|
|
assert!(json["build_hash"].is_string());
|
|
assert!(json["dirs"]["base"].is_string());
|
|
}
|
|
|
|
#[test]
|
|
fn status_empty_dir_shows_empty() {
|
|
let home = TempTidalHome::new().expect("create temp home");
|
|
let paths = home.paths();
|
|
paths.ensure_all().expect("create subdirs");
|
|
|
|
let output = tidalctl_bin()
|
|
.args(["status", "--path", home.path().to_str().unwrap()])
|
|
.output()
|
|
.expect("run tidalctl");
|
|
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
|
let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
|
|
assert_eq!(json["status"], "empty");
|
|
}
|
|
|
|
#[test]
|
|
fn status_nonexistent_path_exits_0_with_empty() {
|
|
// When the WAL dir doesn't exist, gather_wal_state returns segments=0
|
|
// which maps to "empty" status. The base dir may or may not exist.
|
|
let output = tidalctl_bin()
|
|
.args(["status", "--path", "/nonexistent/path/that/does/not/exist"])
|
|
.output()
|
|
.expect("run tidalctl");
|
|
|
|
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
|
// This should still be valid JSON regardless of status
|
|
if output.status.success() {
|
|
let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
|
|
assert!(
|
|
json["status"] == "empty" || json["status"] == "error",
|
|
"status should be empty or error: {stdout}"
|
|
);
|
|
} else {
|
|
let stderr = String::from_utf8(output.stderr).expect("utf8");
|
|
let json: serde_json::Value = serde_json::from_str(&stderr).expect("parse error JSON");
|
|
assert!(json["error"].is_string());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn paths_exits_0_with_all_dirs() {
|
|
let home = TempTidalHome::new().expect("create temp home");
|
|
let paths = home.paths();
|
|
paths.ensure_all().expect("create subdirs");
|
|
|
|
let output = tidalctl_bin()
|
|
.args(["paths", "--path", home.path().to_str().unwrap()])
|
|
.output()
|
|
.expect("run tidalctl");
|
|
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
|
let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
|
|
|
|
// Check all six directory paths are present
|
|
assert!(json["base"].is_string(), "missing base: {stdout}");
|
|
assert!(json["wal"].is_string(), "missing wal: {stdout}");
|
|
assert!(json["items"].is_string(), "missing items: {stdout}");
|
|
assert!(json["users"].is_string(), "missing users: {stdout}");
|
|
assert!(json["creators"].is_string(), "missing creators: {stdout}");
|
|
assert!(json["cache"].is_string(), "missing cache: {stdout}");
|
|
|
|
// Check exists map
|
|
assert!(json["exists"]["base"].is_boolean());
|
|
assert!(json["exists"]["wal"].is_boolean());
|
|
assert_eq!(json["exists"]["base"], true);
|
|
assert_eq!(json["exists"]["wal"], true);
|
|
}
|
|
|
|
#[test]
|
|
fn paths_nonexistent_dir_shows_false_exists() {
|
|
let output = tidalctl_bin()
|
|
.args(["paths", "--path", "/nonexistent/does/not/exist"])
|
|
.output()
|
|
.expect("run tidalctl");
|
|
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
|
let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
|
|
|
|
assert_eq!(json["exists"]["base"], false);
|
|
assert_eq!(json["exists"]["wal"], false);
|
|
}
|
|
|
|
#[test]
|
|
fn pretty_flag_produces_indented_json() {
|
|
let home = TempTidalHome::new().expect("create temp home");
|
|
|
|
let output = tidalctl_bin()
|
|
.args(["paths", "--path", home.path().to_str().unwrap(), "--pretty"])
|
|
.output()
|
|
.expect("run tidalctl");
|
|
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
|
// Pretty-printed JSON should have newlines and indentation
|
|
assert!(
|
|
stdout.contains('\n'),
|
|
"pretty output should have newlines: {stdout}"
|
|
);
|
|
assert!(
|
|
stdout.contains(" "),
|
|
"pretty output should have indentation: {stdout}"
|
|
);
|
|
// Should still be valid JSON
|
|
let _json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
|
|
}
|
|
|
|
#[test]
|
|
fn bad_command_exits_1() {
|
|
let output = tidalctl_bin()
|
|
.args(["nonexistent_command", "--path", "/tmp"])
|
|
.output()
|
|
.expect("run tidalctl");
|
|
|
|
assert!(!output.status.success(), "should exit 1 for bad command");
|
|
let stderr = String::from_utf8(output.stderr).expect("utf8");
|
|
assert!(
|
|
stderr.contains("error"),
|
|
"stderr should contain error: {stderr}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn no_args_exits_1() {
|
|
let output = tidalctl_bin().output().expect("run tidalctl");
|
|
|
|
assert!(!output.status.success(), "should exit 1 with no args");
|
|
}
|
|
|
|
#[test]
|
|
fn missing_path_flag_exits_1() {
|
|
let output = tidalctl_bin()
|
|
.args(["status"])
|
|
.output()
|
|
.expect("run tidalctl");
|
|
|
|
assert!(!output.status.success(), "should exit 1 without --path");
|
|
let stderr = String::from_utf8(output.stderr).expect("utf8");
|
|
assert!(
|
|
stderr.contains("error"),
|
|
"stderr should contain error: {stderr}"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// recover --verify-only tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn recover_verify_only_with_wal_data_json() {
|
|
let home = home_with_wal_data();
|
|
|
|
let output = tidalctl_bin()
|
|
.args([
|
|
"recover",
|
|
"--verify-only",
|
|
"--path",
|
|
home.path().to_str().unwrap(),
|
|
])
|
|
.output()
|
|
.expect("run tidalctl");
|
|
|
|
assert!(
|
|
output.status.success(),
|
|
"exit code: {}, stderr: {}",
|
|
output.status,
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
|
|
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
|
let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
|
|
|
|
assert!(
|
|
json["total_events"].as_u64().unwrap_or(0) > 0,
|
|
"should have total_events: {stdout}"
|
|
);
|
|
assert!(
|
|
json["segment_count"].as_u64().unwrap_or(0) > 0,
|
|
"should have segments: {stdout}"
|
|
);
|
|
assert!(
|
|
json["checkpoint_seq"].as_u64().unwrap_or(0) > 0,
|
|
"should have checkpoint: {stdout}"
|
|
);
|
|
assert!(
|
|
json["estimated_recovery_secs"].as_f64().unwrap_or(0.0) > 0.0,
|
|
"should have recovery time estimate: {stdout}"
|
|
);
|
|
assert!(
|
|
json["segments"].is_array(),
|
|
"should have segments array: {stdout}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn recover_verify_only_empty_dir() {
|
|
let home = TempTidalHome::new().expect("create temp home");
|
|
let paths = home.paths();
|
|
paths.ensure_all().expect("create subdirs");
|
|
|
|
let output = tidalctl_bin()
|
|
.args([
|
|
"recover",
|
|
"--verify-only",
|
|
"--path",
|
|
home.path().to_str().unwrap(),
|
|
])
|
|
.output()
|
|
.expect("run tidalctl");
|
|
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
|
let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
|
|
|
|
assert_eq!(json["total_events"], 0);
|
|
assert_eq!(json["segment_count"], 0);
|
|
assert_eq!(json["inconsistency_count"], 0);
|
|
}
|
|
|
|
#[test]
|
|
fn recover_verify_only_pretty_output() {
|
|
let home = home_with_wal_data();
|
|
|
|
let output = tidalctl_bin()
|
|
.args([
|
|
"recover",
|
|
"--verify-only",
|
|
"--path",
|
|
home.path().to_str().unwrap(),
|
|
"--pretty",
|
|
])
|
|
.output()
|
|
.expect("run tidalctl");
|
|
|
|
assert!(
|
|
output.status.success(),
|
|
"exit code: {}, stderr: {}",
|
|
output.status,
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
|
|
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
|
assert!(
|
|
stdout.contains("WAL Diagnostic Report"),
|
|
"should contain report header: {stdout}"
|
|
);
|
|
assert!(
|
|
stdout.contains("Checkpoint:"),
|
|
"should contain checkpoint section: {stdout}"
|
|
);
|
|
assert!(
|
|
stdout.contains("Events:"),
|
|
"should contain events section: {stdout}"
|
|
);
|
|
assert!(
|
|
stdout.contains("Estimated Recovery Time:"),
|
|
"should contain recovery estimate: {stdout}"
|
|
);
|
|
assert!(
|
|
stdout.contains("Segment Inventory:"),
|
|
"should contain segment table: {stdout}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn recover_without_verify_only_exits_1() {
|
|
let home = TempTidalHome::new().expect("create temp home");
|
|
|
|
let output = tidalctl_bin()
|
|
.args(["recover", "--path", home.path().to_str().unwrap()])
|
|
.output()
|
|
.expect("run tidalctl");
|
|
|
|
assert!(
|
|
!output.status.success(),
|
|
"should exit 1 without --verify-only"
|
|
);
|
|
let stderr = String::from_utf8(output.stderr).expect("utf8");
|
|
assert!(
|
|
stderr.contains("verify-only"),
|
|
"stderr should mention verify-only: {stderr}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn recover_nonexistent_path_returns_empty_report() {
|
|
let output = tidalctl_bin()
|
|
.args([
|
|
"recover",
|
|
"--verify-only",
|
|
"--path",
|
|
"/nonexistent/path/that/does/not/exist",
|
|
])
|
|
.output()
|
|
.expect("run tidalctl");
|
|
|
|
// Should succeed with an empty report (no WAL dir = empty report).
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
|
let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
|
|
assert_eq!(json["total_events"], 0);
|
|
assert_eq!(json["segment_count"], 0);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// diagnostics tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn diagnostics_json_output_valid() {
|
|
let home = home_with_wal_data();
|
|
|
|
let output = tidalctl_bin()
|
|
.args(["diagnostics", "--path", home.path().to_str().unwrap()])
|
|
.output()
|
|
.expect("run tidalctl diagnostics");
|
|
|
|
assert!(
|
|
output.status.success(),
|
|
"exit code: {}, stderr: {}",
|
|
output.status.code().unwrap_or(-1),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
|
|
let stdout = String::from_utf8(output.stdout).expect("valid utf8");
|
|
let json: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON");
|
|
assert!(json["version"].is_string());
|
|
assert!(json["wal_segments"].is_number());
|
|
assert!(json["checkpoint_age_seconds"].is_number());
|
|
assert!(json["tantivy_segments"].is_number());
|
|
assert!(json["tantivy_indexed_docs"].is_number());
|
|
assert!(json["checkpoint_wal_sequence"].is_number());
|
|
assert!(json["wal_total_bytes"].is_number());
|
|
assert!(json["usearch_directory_bytes"].is_number());
|
|
assert!(json["degradation_level"].is_number());
|
|
assert!(json["collection_count"].is_number());
|
|
assert!(json["cohort_count"].is_number());
|
|
}
|
|
|
|
#[test]
|
|
fn diagnostics_pretty_output_readable() {
|
|
let home = home_with_wal_data();
|
|
|
|
let output = tidalctl_bin()
|
|
.args([
|
|
"diagnostics",
|
|
"--path",
|
|
home.path().to_str().unwrap(),
|
|
"--pretty",
|
|
])
|
|
.output()
|
|
.expect("run tidalctl diagnostics");
|
|
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(
|
|
stdout.contains("tidalDB Diagnostics"),
|
|
"missing header: {stdout}"
|
|
);
|
|
assert!(stdout.contains("WAL"), "missing WAL section: {stdout}");
|
|
assert!(
|
|
stdout.contains("Checkpoint"),
|
|
"missing Checkpoint section: {stdout}"
|
|
);
|
|
assert!(
|
|
stdout.contains("Signal Ledger"),
|
|
"missing Signal Ledger section: {stdout}"
|
|
);
|
|
assert!(
|
|
stdout.contains("Text Index (Tantivy)"),
|
|
"missing Text Index section: {stdout}"
|
|
);
|
|
assert!(
|
|
stdout.contains("Vector Index (USearch)"),
|
|
"missing Vector Index section: {stdout}"
|
|
);
|
|
assert!(
|
|
stdout.contains("Sessions"),
|
|
"missing Sessions section: {stdout}"
|
|
);
|
|
assert!(
|
|
stdout.contains("Degradation"),
|
|
"missing Degradation section: {stdout}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn diagnostics_missing_dir_exits_1() {
|
|
let output = tidalctl_bin()
|
|
.args([
|
|
"diagnostics",
|
|
"--path",
|
|
"/nonexistent/tidaldb/diagnostics/path",
|
|
])
|
|
.output()
|
|
.expect("run tidalctl diagnostics");
|
|
assert_eq!(output.status.code(), Some(1));
|
|
}
|