feat(nbd): implement CLI commands and integration tests (Phase 5)
Wire up clap subcommands to storage and display layers: - Cli struct with global --json flag and Create/Read/List/Update subcommands - cmd_create: generates ID, validates priority and deps, writes and prints ticket - cmd_read: looks up ticket by ID and prints it - cmd_list: lists all tickets sorted by priority - cmd_update: reads existing ticket, merges only provided flags, writes and prints - parse_status, parse_ticket_type, parse_deps, validate_deps helpers - 8 integration tests using process::Command against a tempdir - Fix clippy: map_or(false, …) → is_some_and(…) in store.rs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>quotesdb
parent
e9b6e97116
commit
78874df7a6
@ -1,5 +1,273 @@
|
||||
//! 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/` from a subdirectory.
|
||||
//! 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"
|
||||
);
|
||||
}
|
||||
|
||||
/// `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");
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue