You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
vibed/nbd/tests/integration.rs

479 lines
16 KiB
Rust

//! Integration tests for `nbd`.
//!
//! Tests full command flows (create → read → list → update) against a real
//! temporary directory and verifies that directory traversal correctly locates
//! `.nbd/` when the binary is run from a nested subdirectory.
use std::fs;
use std::path::{Path, PathBuf};
use tempfile::TempDir;
// ── Test environment helper ───────────────────────────────────────────────────
/// A temporary project environment with `.nbd/tickets/` already initialised.
struct TestEnv {
/// Keep `TempDir` alive — dropping it would delete the directory.
_tmp: TempDir,
/// The project root (the directory that contains `.nbd/`).
pub root: PathBuf,
}
impl TestEnv {
/// Create a fresh temporary environment with `.nbd/tickets/` ready.
fn new() -> Self {
let tmp = tempfile::tempdir().expect("failed to create tempdir");
let root = tmp.path().to_path_buf();
fs::create_dir_all(root.join(".nbd").join("tickets"))
.expect("failed to create .nbd/tickets/");
TestEnv { _tmp: tmp, root }
}
/// Spawn `nbd` with `args`, using `dir` as the working directory.
fn run_from(&self, dir: &Path, args: &[&str]) -> std::process::Output {
std::process::Command::new(env!("CARGO_BIN_EXE_nbd"))
.args(args)
.current_dir(dir)
.output()
.expect("failed to spawn nbd")
}
/// Spawn `nbd` with `args` from the project root.
fn run(&self, args: &[&str]) -> std::process::Output {
self.run_from(&self.root, args)
}
/// Run `nbd create` with the given extra arguments and return the printed
/// ticket ID extracted from stdout.
fn create(&self, extra_args: &[&str]) -> String {
let mut args = vec!["create"];
args.extend_from_slice(extra_args);
let output = self.run(&args);
assert!(
output.status.success(),
"create failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).unwrap();
stdout
.lines()
.find(|l| l.trim_start().starts_with("ID:"))
.and_then(|l| l.split_whitespace().last())
.expect("could not extract ID from create output")
.to_string()
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
/// `create` then `read` produces a ticket with the expected field values.
#[test]
fn create_and_read_roundtrip() {
let env = TestEnv::new();
let id = env.create(&[
"--title",
"Fix login bug",
"--body",
"Users cannot log in with email addresses containing +",
"--priority",
"8",
"--type",
"bug",
]);
let read = env.run(&["read", &id]);
assert!(
read.status.success(),
"read failed: {}",
String::from_utf8_lossy(&read.stderr)
);
let stdout = String::from_utf8(read.stdout).unwrap();
assert!(stdout.contains(&id), "output should contain the ID");
assert!(
stdout.contains("Fix login bug"),
"output should contain the title"
);
assert!(
stdout.contains("Users cannot log in"),
"output should contain the body"
);
assert!(stdout.contains('8'), "output should contain the priority");
assert!(stdout.contains("bug"), "output should contain the type");
}
/// `list` displays all previously created tickets.
#[test]
fn list_shows_created_tickets() {
let env = TestEnv::new();
env.create(&["--title", "First ticket"]);
env.create(&["--title", "Second ticket"]);
let list = env.run(&["list"]);
assert!(list.status.success());
let stdout = String::from_utf8(list.stdout).unwrap();
assert!(
stdout.contains("First ticket"),
"list should contain first ticket"
);
assert!(
stdout.contains("Second ticket"),
"list should contain second ticket"
);
}
/// `update` changes only the specified fields; others are preserved.
#[test]
fn update_merges_correctly() {
let env = TestEnv::new();
let id = env.create(&[
"--title",
"Original title",
"--priority",
"5",
"--type",
"task",
]);
// Update only the status; title and priority should be unchanged.
let update = env.run(&["update", &id, "--status", "in_progress"]);
assert!(
update.status.success(),
"update failed: {}",
String::from_utf8_lossy(&update.stderr)
);
let read = env.run(&["read", &id]);
let stdout = String::from_utf8(read.stdout).unwrap();
assert!(
stdout.contains("in_progress"),
"status should be updated to in_progress"
);
assert!(
stdout.contains("Original title"),
"title should be unchanged"
);
assert!(stdout.contains('5'), "priority should be unchanged");
}
/// `read` works when executed from a nested subdirectory (traversal test).
#[test]
fn traversal_from_subdir() {
let env = TestEnv::new();
let id = env.create(&["--title", "Traversal test"]);
// Create a nested subdirectory two levels deep.
let subdir = env.root.join("sub").join("dir");
fs::create_dir_all(&subdir).unwrap();
let read = env.run_from(&subdir, &["read", &id]);
assert!(
read.status.success(),
"traversal failed: {}",
String::from_utf8_lossy(&read.stderr)
);
let stdout = String::from_utf8(read.stdout).unwrap();
assert!(
stdout.contains("Traversal test"),
"output should contain the title"
);
}
/// `read` with an unknown ID exits non-zero and mentions the ID in stderr.
#[test]
fn error_on_unknown_id() {
let env = TestEnv::new();
let result = env.run(&["read", "ffffff"]);
assert!(
!result.status.success(),
"reading an unknown ID should exit non-zero"
);
let stderr = String::from_utf8(result.stderr).unwrap();
assert!(
stderr.contains("ffffff"),
"error message should mention the ticket ID, got: {stderr}"
);
}
/// `create --json` outputs valid JSON containing the correct field values.
#[test]
fn create_with_json_flag() {
let env = TestEnv::new();
let output = env.run(&[
"create",
"--title",
"JSON test",
"--priority",
"7",
"--json",
]);
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).unwrap();
let parsed: serde_json::Value =
serde_json::from_str(&stdout).expect("--json output should be valid JSON");
assert_eq!(parsed["title"], "JSON test");
assert_eq!(parsed["priority"], 7);
}
/// `list --json` outputs a valid JSON array with one object per ticket.
#[test]
fn list_with_json_flag() {
let env = TestEnv::new();
env.create(&["--title", "First"]);
env.create(&["--title", "Second"]);
let output = env.run(&["list", "--json"]);
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).unwrap();
let parsed: serde_json::Value =
serde_json::from_str(&stdout).expect("--json list output should be valid JSON");
assert!(parsed.is_array(), "output should be a JSON array");
assert_eq!(
parsed.as_array().unwrap().len(),
2,
"array should contain exactly two tickets"
);
}
/// `create` writes a JSON file that does NOT contain an `"id"` key.
#[test]
fn created_file_omits_id_field() {
let env = TestEnv::new();
let id = env.create(&["--title", "ID key check"]);
// Read the raw file bytes and check the JSON.
let ticket_file = env
.root
.join(".nbd")
.join("tickets")
.join(format!("{id}.json"));
let contents = fs::read_to_string(&ticket_file).expect("ticket file should exist");
let parsed: serde_json::Value =
serde_json::from_str(&contents).expect("ticket file should be valid JSON");
assert!(
parsed.get("id").is_none(),
"written ticket file must not contain an 'id' key, got: {contents}"
);
}
/// `read --json` outputs the correct id even though the file has no `id` field.
#[test]
fn read_json_contains_correct_id() {
let env = TestEnv::new();
let id = env.create(&["--title", "ID injection check"]);
let output = env.run(&["read", &id, "--json"]);
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).unwrap();
let parsed: serde_json::Value =
serde_json::from_str(&stdout).expect("--json read output should be valid JSON");
assert_eq!(
parsed["id"], id,
"read --json output must contain the correct id"
);
}
/// `migrate` re-writes old-format files that contain a stale `"id"` key.
#[test]
fn migrate_rewrites_old_format_files() {
let env = TestEnv::new();
// Manually write an old-format ticket file with "id" in the JSON body.
let old_json = r#"{"id":"abcdef","title":"Old format ticket","body":"","priority":5,"status":"todo","dependencies":[],"ticket_type":"task"}"#;
let ticket_file = env.root.join(".nbd").join("tickets").join("abcdef.json");
fs::write(&ticket_file, old_json).unwrap();
let output = env.run(&["migrate"]);
assert!(
output.status.success(),
"migrate failed: {}",
String::from_utf8_lossy(&output.stderr)
);
// Verify the file no longer contains the "id" key.
let contents = fs::read_to_string(&ticket_file).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap();
assert!(
parsed.get("id").is_none(),
"migrated file must not contain 'id', got: {contents}"
);
}
/// `migrate --dry-run` does not modify files.
#[test]
fn migrate_dry_run_does_not_write() {
let env = TestEnv::new();
let old_json = r#"{"id":"112233","title":"Dry run test","body":"","priority":5,"status":"todo","dependencies":[],"ticket_type":"task"}"#;
let ticket_file = env.root.join(".nbd").join("tickets").join("112233.json");
fs::write(&ticket_file, old_json).unwrap();
let output = env.run(&["migrate", "--dry-run"]);
assert!(output.status.success());
// File must remain unchanged.
let contents = fs::read_to_string(&ticket_file).unwrap();
assert_eq!(contents, old_json, "dry-run must not modify files");
}
/// `migrate` exits zero even when some ticket files cannot be parsed.
#[test]
fn migrate_exits_zero_on_parse_errors() {
let env = TestEnv::new();
let bad_json = b"{ not valid json at all }";
let ticket_file = env.root.join(".nbd").join("tickets").join("badbad.json");
fs::write(&ticket_file, bad_json).unwrap();
let output = env.run(&["migrate"]);
assert!(
output.status.success(),
"migrate should exit zero even with parse errors"
);
// File must remain unchanged.
let contents = fs::read(&ticket_file).unwrap();
assert_eq!(
contents.as_slice(),
bad_json,
"errored file must be left unchanged"
);
}
/// `migrate --json` outputs valid JSON with the expected keys.
#[test]
fn migrate_with_json_flag() {
let env = TestEnv::new();
// Write one old-format and one valid ticket.
env.create(&["--title", "Normal ticket"]);
let old_json = r#"{"id":"xxyyzz","title":"Legacy","body":"","priority":5,"status":"todo","dependencies":[],"ticket_type":"task"}"#;
let ticket_file = env.root.join(".nbd").join("tickets").join("xxyyzz.json");
fs::write(&ticket_file, old_json).unwrap();
let output = env.run(&["migrate", "--json"]);
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).unwrap();
let parsed: serde_json::Value =
serde_json::from_str(&stdout).expect("--json output should be valid JSON");
assert!(
parsed.get("updated").is_some(),
"JSON should have 'updated' key"
);
assert!(
parsed.get("already_current").is_some(),
"JSON should have 'already_current' key"
);
assert!(
parsed.get("errors").is_some(),
"JSON should have 'errors' key"
);
assert_eq!(parsed["updated"], 1, "one file should be migrated");
assert_eq!(parsed["already_current"], 1, "one file should be current");
}
/// `read` accepts a unique prefix instead of a full 6-char ID.
#[test]
fn read_with_prefix() {
let env = TestEnv::new();
let id = env.create(&["--title", "Prefix read test"]);
// Use a 3-char prefix.
let prefix = &id[..3];
let read = env.run(&["read", prefix]);
assert!(
read.status.success(),
"read with prefix failed: {}",
String::from_utf8_lossy(&read.stderr)
);
let stdout = String::from_utf8(read.stdout).unwrap();
assert!(
stdout.contains("Prefix read test"),
"output should contain the title"
);
}
/// `update` accepts a unique prefix instead of a full 6-char ID.
#[test]
fn update_with_prefix() {
let env = TestEnv::new();
let id = env.create(&["--title", "Prefix update test"]);
let prefix = &id[..3];
let update = env.run(&["update", prefix, "--status", "done"]);
assert!(
update.status.success(),
"update with prefix failed: {}",
String::from_utf8_lossy(&update.stderr)
);
let read = env.run(&["read", &id, "--json"]);
let stdout = String::from_utf8(read.stdout).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert_eq!(parsed["status"], "done", "status should be updated to done");
}
/// An ambiguous prefix exits non-zero with an informative message.
#[test]
fn ambiguous_prefix_exits_nonzero() {
let env = TestEnv::new();
// We can't reliably generate two IDs that share a 3-char prefix, but we
// can manually write two ticket files whose names share a prefix.
let ticket_dir = env.root.join(".nbd").join("tickets");
let json = r#"{"title":"A","body":"","priority":5,"status":"todo","dependencies":[],"ticket_type":"task"}"#;
fs::write(ticket_dir.join("ff0001.json"), json).unwrap();
fs::write(ticket_dir.join("ff0002.json"), json).unwrap();
let result = env.run(&["read", "ff0"]);
assert!(
!result.status.success(),
"ambiguous prefix should exit non-zero"
);
let stderr = String::from_utf8(result.stderr).unwrap();
assert!(
stderr.contains("ambiguous"),
"error should say ambiguous, got: {stderr}"
);
}
/// `update --deps` replaces the dependency list.
#[test]
fn update_deps_replaces_list() {
let env = TestEnv::new();
// Create two tickets to use as deps.
let dep1 = env.create(&["--title", "Dep one"]);
let dep2 = env.create(&["--title", "Dep two"]);
let main_id = env.create(&["--title", "Main ticket"]);
// Add dep1 as a dependency.
let update = env.run(&["update", &main_id, "--deps", &dep1]);
assert!(
update.status.success(),
"update with deps failed: {}",
String::from_utf8_lossy(&update.stderr)
);
// Replace with dep2.
let update2 = env.run(&["update", &main_id, "--deps", &dep2]);
assert!(update2.status.success());
let read = env.run(&["read", &main_id, "--json"]);
let stdout = String::from_utf8(read.stdout).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let deps = parsed["dependencies"].as_array().unwrap();
assert_eq!(deps.len(), 1, "should have exactly one dependency");
assert_eq!(deps[0], dep2, "dependency should be dep2");
}