//! 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" ); } /// `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"); }