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.
966 lines
32 KiB
Rust
966 lines
32 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}"
|
|
);
|
|
}
|
|
|
|
/// `nbd init` creates `.nbd/tickets/` in a fresh directory.
|
|
#[test]
|
|
fn init_creates_tickets_dir() {
|
|
let tmp = tempfile::tempdir().expect("failed to create tempdir");
|
|
let root = tmp.path();
|
|
|
|
let output = std::process::Command::new(env!("CARGO_BIN_EXE_nbd"))
|
|
.args(["init"])
|
|
.current_dir(root)
|
|
.output()
|
|
.expect("failed to spawn nbd");
|
|
|
|
assert!(
|
|
output.status.success(),
|
|
"init failed: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
assert!(
|
|
root.join(".nbd").join("tickets").is_dir(),
|
|
".nbd/tickets/ should be created"
|
|
);
|
|
}
|
|
|
|
/// `nbd init` is idempotent — running it twice succeeds.
|
|
#[test]
|
|
fn init_is_idempotent() {
|
|
let tmp = tempfile::tempdir().expect("failed to create tempdir");
|
|
let root = tmp.path();
|
|
|
|
for _ in 0..2 {
|
|
let output = std::process::Command::new(env!("CARGO_BIN_EXE_nbd"))
|
|
.args(["init"])
|
|
.current_dir(root)
|
|
.output()
|
|
.expect("failed to spawn nbd");
|
|
assert!(
|
|
output.status.success(),
|
|
"init failed on repeat: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
}
|
|
assert!(root.join(".nbd").join("tickets").is_dir());
|
|
}
|
|
|
|
/// `nbd init --json` outputs valid JSON with a `root` field.
|
|
#[test]
|
|
fn init_with_json_flag() {
|
|
let tmp = tempfile::tempdir().expect("failed to create tempdir");
|
|
let root = tmp.path();
|
|
|
|
let output = std::process::Command::new(env!("CARGO_BIN_EXE_nbd"))
|
|
.args(["init", "--json"])
|
|
.current_dir(root)
|
|
.output()
|
|
.expect("failed to spawn nbd");
|
|
|
|
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("root").is_some(),
|
|
"JSON should have 'root' field, got: {stdout}"
|
|
);
|
|
}
|
|
|
|
/// `nbd ready` returns only unblocked, non-done tickets.
|
|
#[test]
|
|
fn ready_returns_unblocked_tickets() {
|
|
let env = TestEnv::new();
|
|
|
|
// A: no deps, todo → ready
|
|
let a = env.create(&["--title", "Ticket A"]);
|
|
// B: depends on A, todo → not ready
|
|
env.run(&["create", "--title", "Ticket B", "--deps", &a, "--json"]);
|
|
// C: no deps, done → not ready (already done)
|
|
let c = env.create(&["--title", "Ticket C"]);
|
|
env.run(&["update", &c, "--status", "done"]);
|
|
|
|
let output = env.run(&["ready", "--json"]);
|
|
assert!(
|
|
output.status.success(),
|
|
"ready failed: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
let stdout = String::from_utf8(output.stdout).unwrap();
|
|
let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("should be valid JSON");
|
|
let arr = parsed.as_array().expect("should be array");
|
|
assert_eq!(arr.len(), 1, "only A should be ready");
|
|
assert_eq!(arr[0]["title"], "Ticket A");
|
|
}
|
|
|
|
/// `nbd ready` includes ticket B after its dependency A is marked done.
|
|
#[test]
|
|
fn ready_updates_after_dep_done() {
|
|
let env = TestEnv::new();
|
|
|
|
let a = env.create(&["--title", "Dep A"]);
|
|
env.run(&["create", "--title", "Blocked B", "--deps", &a, "--json"]);
|
|
|
|
// Before marking A done: only A is ready.
|
|
let before = env.run(&["ready", "--json"]);
|
|
let before_str = String::from_utf8(before.stdout).unwrap();
|
|
let before_parsed: serde_json::Value = serde_json::from_str(&before_str).unwrap();
|
|
assert_eq!(before_parsed.as_array().unwrap().len(), 1);
|
|
|
|
// Mark A done.
|
|
env.run(&["update", &a, "--status", "done"]);
|
|
|
|
// Now B is ready.
|
|
let after = env.run(&["ready", "--json"]);
|
|
let after_str = String::from_utf8(after.stdout).unwrap();
|
|
let after_parsed: serde_json::Value = serde_json::from_str(&after_str).unwrap();
|
|
let arr = after_parsed.as_array().unwrap();
|
|
assert_eq!(arr.len(), 1, "only B should be ready now");
|
|
assert_eq!(arr[0]["title"], "Blocked B");
|
|
}
|
|
|
|
/// `nbd ready` returns an empty array when no tickets are actionable.
|
|
#[test]
|
|
fn ready_empty_when_all_done() {
|
|
let env = TestEnv::new();
|
|
|
|
let id = env.create(&["--title", "Finished"]);
|
|
env.run(&["update", &id, "--status", "done"]);
|
|
|
|
let output = env.run(&["ready", "--json"]);
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8(output.stdout).unwrap();
|
|
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
|
|
assert_eq!(
|
|
parsed.as_array().unwrap().len(),
|
|
0,
|
|
"no ready tickets when all are done"
|
|
);
|
|
}
|
|
|
|
/// `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");
|
|
}
|
|
|
|
// ── --filter tests ────────────────────────────────────────────────────────────
|
|
|
|
/// `nbd list --filter type=bug` shows only bug tickets.
|
|
#[test]
|
|
fn list_filter_by_type() {
|
|
let env = TestEnv::new();
|
|
|
|
env.create(&["--title", "Bug ticket", "--type", "bug"]);
|
|
env.create(&["--title", "Task ticket", "--type", "task"]);
|
|
|
|
let output = env.run(&["list", "--filter", "type=bug", "--json"]);
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8(output.stdout).unwrap();
|
|
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
|
|
let arr = parsed.as_array().unwrap();
|
|
assert_eq!(arr.len(), 1, "only the bug ticket should appear");
|
|
assert_eq!(arr[0]["ticket_type"], "bug");
|
|
}
|
|
|
|
/// `nbd list --filter status=in_progress` shows only in_progress tickets.
|
|
#[test]
|
|
fn list_filter_by_status() {
|
|
let env = TestEnv::new();
|
|
|
|
let id = env.create(&["--title", "Active ticket"]);
|
|
env.create(&["--title", "Todo ticket"]);
|
|
env.run(&["update", &id, "--status", "in_progress"]);
|
|
|
|
let output = env.run(&["list", "--filter", "status=in_progress", "--json"]);
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8(output.stdout).unwrap();
|
|
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
|
|
let arr = parsed.as_array().unwrap();
|
|
assert_eq!(arr.len(), 1, "only in_progress ticket should appear");
|
|
assert_eq!(arr[0]["status"], "in_progress");
|
|
}
|
|
|
|
/// Same key repeated is ORed: `--filter status=todo --filter status=in_progress`
|
|
/// shows both todo and in_progress tickets.
|
|
#[test]
|
|
fn list_filter_same_key_ored() {
|
|
let env = TestEnv::new();
|
|
|
|
let id = env.create(&["--title", "Active"]);
|
|
env.create(&["--title", "Todo"]);
|
|
let done_id = env.create(&["--title", "Done"]);
|
|
env.run(&["update", &id, "--status", "in_progress"]);
|
|
env.run(&["update", &done_id, "--status", "done"]);
|
|
|
|
let output = env.run(&[
|
|
"list",
|
|
"--filter",
|
|
"status=todo",
|
|
"--filter",
|
|
"status=in_progress",
|
|
"--json",
|
|
]);
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8(output.stdout).unwrap();
|
|
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
|
|
let arr = parsed.as_array().unwrap();
|
|
assert_eq!(
|
|
arr.len(),
|
|
2,
|
|
"todo and in_progress should appear; done excluded"
|
|
);
|
|
let statuses: Vec<&str> = arr.iter().map(|v| v["status"].as_str().unwrap()).collect();
|
|
assert!(statuses.contains(&"todo"), "todo should be in results");
|
|
assert!(
|
|
statuses.contains(&"in_progress"),
|
|
"in_progress should be in results"
|
|
);
|
|
}
|
|
|
|
/// Different keys are ANDed: `--filter type=bug --filter status=todo` shows
|
|
/// only bug tickets with status todo.
|
|
#[test]
|
|
fn list_filter_different_keys_anded() {
|
|
let env = TestEnv::new();
|
|
|
|
env.create(&["--title", "Bug todo", "--type", "bug"]);
|
|
let bug_active = env.create(&["--title", "Bug active", "--type", "bug"]);
|
|
env.create(&["--title", "Task todo", "--type", "task"]);
|
|
env.run(&["update", &bug_active, "--status", "in_progress"]);
|
|
|
|
let output = env.run(&[
|
|
"list",
|
|
"--filter",
|
|
"type=bug",
|
|
"--filter",
|
|
"status=todo",
|
|
"--json",
|
|
]);
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8(output.stdout).unwrap();
|
|
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
|
|
let arr = parsed.as_array().unwrap();
|
|
assert_eq!(arr.len(), 1, "only bug+todo should appear");
|
|
assert_eq!(arr[0]["title"], "Bug todo");
|
|
}
|
|
|
|
/// `nbd list --filter title=*login*` matches by glob on the title field.
|
|
#[test]
|
|
fn list_filter_title_glob() {
|
|
let env = TestEnv::new();
|
|
|
|
env.create(&["--title", "Fix login button"]);
|
|
env.create(&["--title", "Add rate limiting"]);
|
|
|
|
let output = env.run(&["list", "--filter", "title=*login*", "--json"]);
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8(output.stdout).unwrap();
|
|
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
|
|
let arr = parsed.as_array().unwrap();
|
|
assert_eq!(arr.len(), 1, "only the login ticket should match");
|
|
assert_eq!(arr[0]["title"], "Fix login button");
|
|
}
|
|
|
|
/// `nbd list --filter status=*` wildcard matches all statuses.
|
|
#[test]
|
|
fn list_filter_status_wildcard() {
|
|
let env = TestEnv::new();
|
|
|
|
let id = env.create(&["--title", "A"]);
|
|
env.create(&["--title", "B"]);
|
|
env.run(&["update", &id, "--status", "done"]);
|
|
|
|
let output = env.run(&["list", "--filter", "status=*", "--json"]);
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8(output.stdout).unwrap();
|
|
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
|
|
let arr = parsed.as_array().unwrap();
|
|
assert_eq!(arr.len(), 2, "wildcard should match all statuses");
|
|
}
|
|
|
|
/// `nbd list --filter badformat` (no `=`) exits non-zero.
|
|
#[test]
|
|
fn list_filter_bad_format_exits_nonzero() {
|
|
let env = TestEnv::new();
|
|
|
|
let output = env.run(&["list", "--filter", "badformat"]);
|
|
assert!(
|
|
!output.status.success(),
|
|
"missing '=' in filter should exit non-zero"
|
|
);
|
|
}
|
|
|
|
/// `nbd list --filter unknown=foo` (unknown key) exits non-zero.
|
|
#[test]
|
|
fn list_filter_unknown_key_exits_nonzero() {
|
|
let env = TestEnv::new();
|
|
|
|
let output = env.run(&["list", "--filter", "colour=red"]);
|
|
assert!(
|
|
!output.status.success(),
|
|
"unknown filter key should exit non-zero"
|
|
);
|
|
let stderr = String::from_utf8(output.stderr).unwrap();
|
|
assert!(
|
|
stderr.contains("colour"),
|
|
"error should mention the unknown key, got: {stderr}"
|
|
);
|
|
}
|
|
|
|
/// `nbd ready --filter type=bug` returns only ready bug tickets.
|
|
#[test]
|
|
fn ready_filter_by_type() {
|
|
let env = TestEnv::new();
|
|
|
|
env.create(&["--title", "Ready bug", "--type", "bug"]);
|
|
env.create(&["--title", "Ready task", "--type", "task"]);
|
|
|
|
let output = env.run(&["ready", "--filter", "type=bug", "--json"]);
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8(output.stdout).unwrap();
|
|
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
|
|
let arr = parsed.as_array().unwrap();
|
|
assert_eq!(arr.len(), 1, "only the bug ticket should appear in ready");
|
|
assert_eq!(arr[0]["ticket_type"], "bug");
|
|
}
|
|
|
|
/// `nbd ready --filter priority=8` returns only ready tickets with priority 8.
|
|
#[test]
|
|
fn ready_filter_by_priority() {
|
|
let env = TestEnv::new();
|
|
|
|
env.create(&["--title", "High prio", "--priority", "8"]);
|
|
env.create(&["--title", "Normal prio", "--priority", "5"]);
|
|
|
|
let output = env.run(&["ready", "--filter", "priority=8", "--json"]);
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8(output.stdout).unwrap();
|
|
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
|
|
let arr = parsed.as_array().unwrap();
|
|
assert_eq!(arr.len(), 1, "only priority-8 ticket should appear");
|
|
assert_eq!(arr[0]["priority"], 8);
|
|
}
|
|
|
|
/// `nbd migrate --filter status=todo --dry-run --json` reports skipped count
|
|
/// for tickets that do not match the filter.
|
|
#[test]
|
|
fn migrate_filter_skipped_in_json() {
|
|
let env = TestEnv::new();
|
|
|
|
env.create(&["--title", "Todo ticket"]);
|
|
let id2 = env.create(&["--title", "Active ticket"]);
|
|
env.run(&["update", &id2, "--status", "in_progress"]);
|
|
|
|
let output = env.run(&["migrate", "--filter", "status=todo", "--dry-run", "--json"]);
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8(output.stdout).unwrap();
|
|
let parsed: serde_json::Value =
|
|
serde_json::from_str(&stdout).expect("migrate --json should produce valid JSON");
|
|
assert!(
|
|
parsed.get("skipped").is_some(),
|
|
"JSON should have 'skipped' key"
|
|
);
|
|
assert_eq!(
|
|
parsed["skipped"], 1,
|
|
"one ticket (in_progress) should be skipped"
|
|
);
|
|
}
|
|
|
|
/// `nbd migrate --json` always includes a `skipped` key (even when zero).
|
|
#[test]
|
|
fn migrate_json_always_has_skipped_key() {
|
|
let env = TestEnv::new();
|
|
|
|
env.create(&["--title", "Some ticket"]);
|
|
|
|
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).unwrap();
|
|
assert!(
|
|
parsed.get("skipped").is_some(),
|
|
"JSON should always contain 'skipped' key"
|
|
);
|
|
assert_eq!(parsed["skipped"], 0);
|
|
}
|
|
|
|
/// `nbd ready --filter badformat` exits non-zero.
|
|
#[test]
|
|
fn ready_filter_bad_format_exits_nonzero() {
|
|
let env = TestEnv::new();
|
|
|
|
let output = env.run(&["ready", "--filter", "noequals"]);
|
|
assert!(
|
|
!output.status.success(),
|
|
"bad filter format on ready should exit non-zero"
|
|
);
|
|
}
|
|
|
|
// ── done-exclusion default behaviour ──────────────────────────────────────────
|
|
|
|
/// `nbd list` without any filter excludes done tickets.
|
|
#[test]
|
|
fn list_excludes_done_by_default() {
|
|
let env = TestEnv::new();
|
|
|
|
let todo_id = env.create(&["--title", "Todo ticket"]);
|
|
let done_id = env.create(&["--title", "Done ticket"]);
|
|
env.run(&["update", &done_id, "--status", "done"]);
|
|
|
|
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).unwrap();
|
|
let arr = parsed.as_array().unwrap();
|
|
assert_eq!(arr.len(), 1, "only the todo ticket should appear");
|
|
assert_eq!(arr[0]["id"], todo_id);
|
|
}
|
|
|
|
/// `nbd list --filter status=done` shows only done tickets.
|
|
#[test]
|
|
fn list_filter_status_done_shows_only_done() {
|
|
let env = TestEnv::new();
|
|
|
|
env.create(&["--title", "Todo ticket"]);
|
|
let done_id = env.create(&["--title", "Done ticket"]);
|
|
env.run(&["update", &done_id, "--status", "done"]);
|
|
|
|
let output = env.run(&["list", "--filter", "status=done", "--json"]);
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8(output.stdout).unwrap();
|
|
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
|
|
let arr = parsed.as_array().unwrap();
|
|
assert_eq!(arr.len(), 1, "only the done ticket should appear");
|
|
assert_eq!(arr[0]["id"], done_id);
|
|
}
|
|
|
|
/// `nbd list --filter status=*` includes done tickets.
|
|
#[test]
|
|
fn list_filter_status_wildcard_includes_done() {
|
|
let env = TestEnv::new();
|
|
|
|
env.create(&["--title", "Todo ticket"]);
|
|
let done_id = env.create(&["--title", "Done ticket"]);
|
|
env.run(&["update", &done_id, "--status", "done"]);
|
|
|
|
let output = env.run(&["list", "--filter", "status=*", "--json"]);
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8(output.stdout).unwrap();
|
|
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
|
|
let arr = parsed.as_array().unwrap();
|
|
assert_eq!(arr.len(), 2, "both tickets should appear with status=*");
|
|
}
|
|
|
|
/// `nbd list --filter type=bug` excludes done bug tickets.
|
|
#[test]
|
|
fn list_filter_type_still_excludes_done() {
|
|
let env = TestEnv::new();
|
|
|
|
env.create(&["--title", "Bug todo", "--type", "bug"]);
|
|
let done_bug = env.create(&["--title", "Bug done", "--type", "bug"]);
|
|
env.create(&["--title", "Task todo", "--type", "task"]);
|
|
env.run(&["update", &done_bug, "--status", "done"]);
|
|
|
|
let output = env.run(&["list", "--filter", "type=bug", "--json"]);
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8(output.stdout).unwrap();
|
|
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
|
|
let arr = parsed.as_array().unwrap();
|
|
assert_eq!(arr.len(), 1, "only non-done bug ticket should appear");
|
|
assert_eq!(arr[0]["title"], "Bug todo");
|
|
}
|
|
|
|
/// `nbd list --filter type=bug --filter status=*` includes all bug tickets.
|
|
#[test]
|
|
fn list_filter_type_and_status_wildcard_includes_done_bugs() {
|
|
let env = TestEnv::new();
|
|
|
|
env.create(&["--title", "Bug todo", "--type", "bug"]);
|
|
let done_bug = env.create(&["--title", "Bug done", "--type", "bug"]);
|
|
env.run(&["update", &done_bug, "--status", "done"]);
|
|
|
|
let output = env.run(&[
|
|
"list", "--filter", "type=bug", "--filter", "status=*", "--json",
|
|
]);
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8(output.stdout).unwrap();
|
|
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
|
|
let arr = parsed.as_array().unwrap();
|
|
assert_eq!(
|
|
arr.len(),
|
|
2,
|
|
"both bug tickets should appear when status=* is set"
|
|
);
|
|
}
|