tests/cli_test.rs
Ref: Size: 30.4 KiB
mod common;
use common::TestRepo;
// ===========================================================================
// Issue commands
// ===========================================================================
#[test]
fn test_issue_open_and_show() {
let repo = TestRepo::new("Alice", "alice@example.com");
let out = repo.run_ok(&["issue", "open", "-t", "My first bug"]);
assert!(out.starts_with("Opened issue "));
let id = out.trim().strip_prefix("Opened issue ").unwrap();
assert_eq!(id.len(), 8, "should print 8-char short ID");
let out = repo.run_ok(&["issue", "show", id]);
assert!(out.contains("My first bug"));
assert!(out.contains("[open]"));
assert!(out.contains("Alice"));
}
#[test]
fn test_issue_open_with_body() {
let repo = TestRepo::new("Alice", "alice@example.com");
let out = repo.run_ok(&["issue", "open", "-t", "Bug", "-b", "Steps to reproduce..."]);
let id = out.trim().strip_prefix("Opened issue ").unwrap();
let out = repo.run_ok(&["issue", "show", id]);
assert!(out.contains("Steps to reproduce..."));
}
#[test]
fn test_issue_list_filters_closed() {
let repo = TestRepo::new("Alice", "alice@example.com");
repo.issue_open("Open bug");
let closed_id = repo.issue_open("Closed bug");
repo.run_ok(&["issue", "close", &closed_id]);
let out = repo.run_ok(&["issue", "list"]);
assert!(out.contains("Open bug"));
assert!(!out.contains("Closed bug"));
// Closed issues are archived, so --all alone won't show them; need --archived
let out = repo.run_ok(&["issue", "list", "--all", "--archived"]);
assert!(out.contains("Open bug"));
assert!(out.contains("Closed bug"));
assert!(out.contains("closed"));
}
#[test]
fn test_issue_comment() {
let repo = TestRepo::new("Alice", "alice@example.com");
let id = repo.issue_open("Discussion");
let out = repo.run_ok(&["issue", "comment", &id, "-b", "First thought"]);
assert!(out.contains("Comment added"));
let out = repo.run_ok(&["issue", "show", &id]);
assert!(out.contains("First thought"));
assert!(out.contains("Comments"));
}
#[test]
fn test_issue_close_with_reason() {
let repo = TestRepo::new("Alice", "alice@example.com");
let id = repo.issue_open("Will close");
repo.run_ok(&["issue", "close", &id, "-r", "Duplicate of #42"]);
let out = repo.run_ok(&["issue", "show", &id]);
assert!(out.contains("[closed]"));
assert!(out.contains("Duplicate of #42"));
}
#[test]
fn test_issue_close_without_reason() {
let repo = TestRepo::new("Alice", "alice@example.com");
let id = repo.issue_open("Close silently");
let out = repo.run_ok(&["issue", "close", &id]);
assert!(out.contains("Issue closed"));
let out = repo.run_ok(&["issue", "show", &id]);
assert!(out.contains("[closed]"));
}
#[test]
fn test_issue_prefix_resolution() {
let repo = TestRepo::new("Alice", "alice@example.com");
let id = repo.issue_open("Prefix test");
// Use first 4 chars as prefix
let short = &id[..4];
let out = repo.run_ok(&["issue", "show", short]);
assert!(out.contains("Prefix test"));
}
#[test]
fn test_issue_nonexistent_id() {
let repo = TestRepo::new("Alice", "alice@example.com");
let err = repo.run_err(&["issue", "show", "deadbeef"]);
assert!(err.contains("no issue found"));
}
#[test]
fn test_issue_list_empty() {
let repo = TestRepo::new("Alice", "alice@example.com");
let out = repo.run_ok(&["issue", "list"]);
assert!(out.contains("No issues found"));
}
#[test]
fn test_multiple_issues_independent() {
let repo = TestRepo::new("Alice", "alice@example.com");
let id1 = repo.issue_open("Issue one");
let id2 = repo.issue_open("Issue two");
repo.run_ok(&["issue", "comment", &id1, "-b", "Comment on one"]);
repo.run_ok(&["issue", "close", &id2]);
let out = repo.run_ok(&["issue", "show", &id1]);
assert!(out.contains("[open]"));
assert!(out.contains("Comment on one"));
let out = repo.run_ok(&["issue", "show", &id2]);
assert!(out.contains("[closed]"));
assert!(!out.contains("Comment on one"));
}
// ===========================================================================
// Issue edit
// ===========================================================================
#[test]
fn test_issue_edit_title() {
let repo = TestRepo::new("Alice", "alice@example.com");
let id = repo.issue_open("Old title");
let out = repo.run_ok(&["issue", "edit", &id, "-t", "New title"]);
assert!(out.contains("Issue updated"));
let out = repo.run_ok(&["issue", "show", &id]);
assert!(out.contains("New title"));
assert!(!out.contains("Old title"));
}
#[test]
fn test_issue_edit_body() {
let repo = TestRepo::new("Alice", "alice@example.com");
let out = repo.run_ok(&["issue", "open", "-t", "Bug", "-b", "Old body"]);
let id = out.trim().strip_prefix("Opened issue ").unwrap();
repo.run_ok(&["issue", "edit", id, "-b", "New body with details"]);
let out = repo.run_ok(&["issue", "show", id]);
assert!(out.contains("New body with details"));
assert!(!out.contains("Old body"));
}
#[test]
fn test_issue_edit_title_and_body() {
let repo = TestRepo::new("Alice", "alice@example.com");
let out = repo.run_ok(&["issue", "open", "-t", "Original", "-b", "Original body"]);
let id = out.trim().strip_prefix("Opened issue ").unwrap();
repo.run_ok(&["issue", "edit", id, "-t", "Updated", "-b", "Updated body"]);
let out = repo.run_ok(&["issue", "show", id]);
assert!(out.contains("Updated"));
assert!(out.contains("Updated body"));
}
#[test]
fn test_issue_edit_preserves_comments() {
let repo = TestRepo::new("Alice", "alice@example.com");
let id = repo.issue_open("Will edit");
repo.run_ok(&["issue", "comment", &id, "-b", "A comment before edit"]);
repo.run_ok(&["issue", "edit", &id, "-t", "Edited title"]);
let out = repo.run_ok(&["issue", "show", &id]);
assert!(out.contains("Edited title"));
assert!(out.contains("A comment before edit"));
}
#[test]
fn test_issue_edit_requires_title_or_body() {
let repo = TestRepo::new("Alice", "alice@example.com");
let id = repo.issue_open("No change");
let err = repo.run_err(&["issue", "edit", &id]);
assert!(
err.contains("--title") || err.contains("--body") || err.contains("at least one"),
"should require at least --title or --body, got: {}",
err
);
}
// ===========================================================================
// Issue labels
// ===========================================================================
#[test]
fn test_issue_label_and_show() {
let repo = TestRepo::new("Alice", "alice@example.com");
let id = repo.issue_open("Labeled issue");
let out = repo.run_ok(&["issue", "label", &id, "bug"]);
assert!(out.contains("Label") && out.contains("added"));
let out = repo.run_ok(&["issue", "show", &id]);
assert!(out.contains("bug"));
}
#[test]
fn test_issue_multiple_labels() {
let repo = TestRepo::new("Alice", "alice@example.com");
let id = repo.issue_open("Multi-label issue");
repo.run_ok(&["issue", "label", &id, "bug"]);
repo.run_ok(&["issue", "label", &id, "priority"]);
let out = repo.run_ok(&["issue", "show", &id]);
assert!(out.contains("bug"));
assert!(out.contains("priority"));
}
#[test]
fn test_issue_unlabel() {
let repo = TestRepo::new("Alice", "alice@example.com");
let id = repo.issue_open("Remove label");
repo.run_ok(&["issue", "label", &id, "bug"]);
repo.run_ok(&["issue", "label", &id, "wontfix"]);
repo.run_ok(&["issue", "unlabel", &id, "bug"]);
let out = repo.run_ok(&["issue", "show", &id]);
assert!(!out.contains("bug"));
assert!(out.contains("wontfix"));
}
#[test]
fn test_issue_label_shown_in_list() {
let repo = TestRepo::new("Alice", "alice@example.com");
let id = repo.issue_open("Listed with label");
repo.run_ok(&["issue", "label", &id, "enhancement"]);
let out = repo.run_ok(&["issue", "list"]);
assert!(out.contains("enhancement"));
}
// ===========================================================================
// Issue assignees
// ===========================================================================
#[test]
fn test_issue_assign_and_show() {
let repo = TestRepo::new("Alice", "alice@example.com");
let id = repo.issue_open("Assigned issue");
let out = repo.run_ok(&["issue", "assign", &id, "Bob"]);
assert!(out.contains("Assigned"));
let out = repo.run_ok(&["issue", "show", &id]);
assert!(out.contains("Bob"));
assert!(out.contains("Assignee"));
}
#[test]
fn test_issue_unassign() {
let repo = TestRepo::new("Alice", "alice@example.com");
let id = repo.issue_open("Unassign test");
repo.run_ok(&["issue", "assign", &id, "Bob"]);
repo.run_ok(&["issue", "unassign", &id, "Bob"]);
let out = repo.run_ok(&["issue", "show", &id]);
assert!(!out.contains("Bob") || !out.contains("Assignee"));
}
// ===========================================================================
// Patch commands
// ===========================================================================
#[test]
fn test_patch_create_and_show() {
let repo = TestRepo::new("Alice", "alice@example.com");
repo.commit_file("hello.txt", "hello world", "add hello");
let id = repo.patch_create("Add hello");
let out = repo.run_ok(&["patch", "show", &id]);
assert!(out.contains("Add hello"));
assert!(out.contains("[open]"));
assert!(out.contains("Alice"));
}
#[test]
fn test_patch_list_filters_by_status() {
let repo = TestRepo::new("Alice", "alice@example.com");
repo.patch_create("Open patch");
let closed_id = repo.patch_create("Closed patch");
repo.run_ok(&["patch", "close", &closed_id]);
let out = repo.run_ok(&["patch", "list"]);
assert!(out.contains("Open patch"));
assert!(!out.contains("Closed patch"));
// Closed patches are archived, so --all alone won't show them; need --archived
let out = repo.run_ok(&["patch", "list", "--all", "--archived"]);
assert!(out.contains("Open patch"));
assert!(out.contains("Closed patch"));
}
#[test]
fn test_patch_comment() {
let repo = TestRepo::new("Alice", "alice@example.com");
let id = repo.patch_create("Review me");
let out = repo.run_ok(&["patch", "comment", &id, "-b", "Looks good overall"]);
assert!(out.contains("Comment added"));
let out = repo.run_ok(&["patch", "show", &id]);
assert!(out.contains("Looks good overall"));
assert!(out.contains("Comments"));
}
#[test]
fn test_patch_inline_comment() {
let repo = TestRepo::new("Alice", "alice@example.com");
let id = repo.patch_create("Review me");
repo.run_ok(&[
"patch",
"comment",
&id,
"-b",
"Use const here",
"-f",
"src/main.rs",
"-l",
"42",
]);
let out = repo.run_ok(&["patch", "show", &id]);
assert!(out.contains("Use const here"));
assert!(out.contains("src/main.rs"));
assert!(out.contains("42"));
assert!(out.contains("Inline Comments"));
}
#[test]
fn test_patch_inline_comment_requires_both_file_and_line() {
let repo = TestRepo::new("Alice", "alice@example.com");
let id = repo.patch_create("Review me");
// --file without --line
let err = repo.run_err(&["patch", "comment", &id, "-b", "nope", "-f", "foo.rs"]);
assert!(err.contains("--file and --line must both be provided"));
// --line without --file
let err = repo.run_err(&["patch", "comment", &id, "-b", "nope", "-l", "10"]);
assert!(err.contains("--file and --line must both be provided"));
}
#[test]
fn test_patch_review_approve() {
let repo = TestRepo::new("Alice", "alice@example.com");
let id = repo.patch_create("Feature X");
let out = repo.run_ok(&["patch", "review", &id, "-v", "approve", "-b", "LGTM!"]);
assert!(out.contains("Review submitted"));
let out = repo.run_ok(&["patch", "show", &id]);
assert!(out.contains("LGTM!"));
assert!(out.contains("(approve)"));
assert!(out.contains("Reviews"));
}
#[test]
fn test_patch_review_request_changes() {
let repo = TestRepo::new("Alice", "alice@example.com");
let id = repo.patch_create("Feature Y");
repo.run_ok(&[
"patch",
"review",
&id,
"-v",
"request-changes",
"-b",
"Needs error handling",
]);
let out = repo.run_ok(&["patch", "show", &id]);
assert!(out.contains("(request-changes)"));
assert!(out.contains("Needs error handling"));
}
#[test]
fn test_patch_review_invalid_verdict() {
let repo = TestRepo::new("Alice", "alice@example.com");
let id = repo.patch_create("Feature Z");
let err = repo.run_err(&["patch", "review", &id, "-v", "yolo", "-b", "whatever"]);
assert!(err.contains("verdict must be"));
}
#[test]
fn test_patch_revise() {
let repo = TestRepo::new("Alice", "alice@example.com");
let id = repo.patch_create("WIP feature");
// Switch to the patch's branch and add a new commit
repo.git(&["checkout", "test/wip-feature"]);
repo.commit_file("v2.txt", "v2", "version 2");
repo.git(&["checkout", "main"]);
let out = repo.run_ok(&[
"patch",
"revise",
&id,
"-b",
"Updated implementation",
]);
assert!(out.contains("Patch revised"));
let out = repo.run_ok(&["patch", "show", &id]);
assert!(out.contains("Updated implementation"));
// Branch-based patch show displays the branch name, not the raw head OID
assert!(out.contains("test/wip-feature"));
}
#[test]
fn test_patch_diff() {
let repo = TestRepo::new("Alice", "alice@example.com");
// Create a feature branch with a new file
repo.git(&["checkout", "-b", "feature"]);
repo.commit_file(
"hello.rs",
"fn main() {\n println!(\"hello\");\n}\n",
"add hello",
);
repo.git(&["checkout", "main"]);
let out = repo.run_ok(&["patch", "create", "-t", "Add hello", "-B", "feature"]);
let id = out.trim().strip_prefix("Created patch ").unwrap();
let out = repo.run_ok(&["patch", "diff", id]);
assert!(out.contains("hello.rs"), "should show filename");
assert!(out.contains("fn main()"), "should show added code");
assert!(out.contains("+"), "should show + for additions");
}
#[test]
fn test_patch_diff_no_changes() {
let repo = TestRepo::new("Alice", "alice@example.com");
// Patch pointing at same commit as main — no diff
let id = repo.patch_create("No diff patch");
let out = repo.run_ok(&["patch", "diff", &id]);
assert!(
out.contains("No diff") || out.contains("identical"),
"should indicate no diff, got: {}",
out
);
}
#[test]
fn test_patch_close() {
let repo = TestRepo::new("Alice", "alice@example.com");
let id = repo.patch_create("Will close");
let out = repo.run_ok(&["patch", "close", &id, "-r", "Superseded"]);
assert!(out.contains("Patch closed"));
let out = repo.run_ok(&["patch", "show", &id]);
assert!(out.contains("[closed]"));
}
#[test]
fn test_patch_auto_detect_merge_on_git_merge() {
let repo = TestRepo::new("Alice", "alice@example.com");
// Create a feature branch ahead of main
repo.git(&["checkout", "-b", "feature"]);
repo.commit_file("feature.txt", "new feature", "add feature");
repo.git(&["checkout", "main"]);
// Create patch pointing at the feature branch
let out = repo.run_ok(&["patch", "create", "-t", "Add feature", "-B", "feature"]);
let id = out.trim().strip_prefix("Created patch ").unwrap();
// Merge via git directly
repo.git(&["merge", "feature"]);
// Patch should auto-detect as merged
let out = repo.run_ok(&["patch", "show", id]);
assert!(out.contains("[merged]"));
}
#[test]
fn test_patch_list_empty() {
let repo = TestRepo::new("Alice", "alice@example.com");
let out = repo.run_ok(&["patch", "list"]);
assert!(out.contains("No patches found"));
}
// ===========================================================================
// Cross-references (patch --fixes issue)
// ===========================================================================
#[test]
fn test_patch_create_with_fixes() {
let repo = TestRepo::new("Alice", "alice@example.com");
let issue_id = repo.issue_open("Login bug");
repo.git(&["checkout", "-b", "fix"]);
repo.commit_file("fix.rs", "fixed", "fix login");
repo.git(&["checkout", "main"]);
let out = repo.run_ok(&[
"patch", "create",
"-t", "Fix login bug",
"-B", "fix",
"--fixes", &issue_id,
]);
let patch_id = out.trim().strip_prefix("Created patch ").unwrap();
// Patch show should mention the linked issue
let out = repo.run_ok(&["patch", "show", patch_id]);
assert!(out.contains("Fixes"), "should show Fixes field");
assert!(out.contains(&issue_id[..8]), "should show linked issue ID");
}
// ===========================================================================
// Unread tracking
// ===========================================================================
#[test]
fn test_issue_list_shows_unread_after_new_comments() {
let repo = TestRepo::new("Alice", "alice@example.com");
let id = repo.issue_open("Unread test");
// View the issue to mark it as read
repo.run_ok(&["issue", "show", &id]);
// Add comments after viewing
repo.run_ok(&["issue", "comment", &id, "-b", "New comment 1"]);
repo.run_ok(&["issue", "comment", &id, "-b", "New comment 2"]);
// List should show unread count
let out = repo.run_ok(&["issue", "list"]);
assert!(
out.contains("2 new"),
"should show 2 new events, got: {}",
out
);
}
#[test]
fn test_issue_show_marks_as_read() {
let repo = TestRepo::new("Alice", "alice@example.com");
let id = repo.issue_open("Read test");
repo.run_ok(&["issue", "comment", &id, "-b", "A comment"]);
// View to mark as read
repo.run_ok(&["issue", "show", &id]);
// List should show no unread
let out = repo.run_ok(&["issue", "list"]);
assert!(
!out.contains("new"),
"should not show 'new' after viewing, got: {}",
out
);
}
#[test]
fn test_issue_list_no_unread_for_never_viewed() {
let repo = TestRepo::new("Alice", "alice@example.com");
// A brand new issue that's never been shown should not show "new"
// (only show unread count after the user has viewed it once)
repo.issue_open("Fresh issue");
let out = repo.run_ok(&["issue", "list"]);
assert!(
!out.contains("new"),
"never-viewed issue should not show unread count, got: {}",
out
);
}
// ===========================================================================
// Init command
// ===========================================================================
#[test]
fn test_init_no_remotes() {
let repo = TestRepo::new("Alice", "alice@example.com");
let out = repo.run_ok(&["init"]);
assert!(out.contains("No remotes"));
}
// ===========================================================================
// Top-level status/log commands
// ===========================================================================
#[test]
fn test_status_command_reports_summary_and_recent_items() {
let repo = TestRepo::new("Alice", "alice@example.com");
repo.issue_open("Status bug");
repo.patch_create("Status patch");
let out = repo.run_ok(&["status"]);
assert!(out.contains("Issues: 1 open, 0 closed"), "unexpected status output: {}", out);
assert!(out.contains("Patches: 1 open, 0 merged, 0 closed"), "unexpected status output: {}", out);
assert!(out.contains("Recently updated:"), "unexpected status output: {}", out);
assert!(out.contains("[issue]") && out.contains("Status bug"), "unexpected status output: {}", out);
assert!(out.contains("[patch]") && out.contains("Status patch"), "unexpected status output: {}", out);
}
#[test]
fn test_log_command_shows_recent_collab_events_and_limit() {
let repo = TestRepo::new("Alice", "alice@example.com");
let issue_id = repo.issue_open("Logged issue");
repo.run_ok(&["issue", "comment", &issue_id, "-b", "Logged comment"]);
repo.patch_create("Logged patch");
let out = repo.run_ok(&["log"]);
assert!(out.contains("IssueOpen issue"), "unexpected log output: {}", out);
assert!(out.contains("IssueComment issue"), "unexpected log output: {}", out);
assert!(out.contains("PatchCreate patch"), "unexpected log output: {}", out);
assert!(out.contains("open \"Logged issue\""), "unexpected log output: {}", out);
assert!(out.contains("Logged comment"), "unexpected log output: {}", out);
assert!(out.contains("create \"Logged patch\""), "unexpected log output: {}", out);
let limited = repo.run_ok(&["log", "-n", "2"]);
assert_eq!(limited.lines().count(), 2, "expected exactly 2 log lines, got: {}", limited);
}
// ===========================================================================
// Full scenario tests
// ===========================================================================
#[test]
fn test_full_issue_lifecycle() {
let repo = TestRepo::new("Alice", "alice@example.com");
// Open
let id = repo.issue_open("Login page crashes");
// Multiple comments
repo.run_ok(&["issue", "comment", &id, "-b", "Stack trace attached"]);
repo.run_ok(&["issue", "comment", &id, "-b", "Reproduced on Chrome"]);
let out = repo.run_ok(&["issue", "show", &id]);
assert!(out.contains("Stack trace attached"));
assert!(out.contains("Reproduced on Chrome"));
// Close with reason (this archives the issue)
repo.run_ok(&["issue", "close", &id, "-r", "Fixed in commit abc123"]);
let out = repo.run_ok(&["issue", "show", &id]);
assert!(out.contains("[closed]"));
assert!(out.contains("Fixed in commit abc123"));
// Issue should be archived and not in default list
let out = repo.run_ok(&["issue", "list"]);
assert!(!out.contains("Login page crashes") || out.contains("No issues"));
// But visible with --archived
let out = repo.run_ok(&["issue", "list", "--all", "--archived"]);
assert!(out.contains("Login page crashes"));
}
#[test]
fn test_full_patch_review_cycle() {
let repo = TestRepo::new("Alice", "alice@example.com");
// Create feature branch with v1
repo.git(&["checkout", "-b", "feature"]);
repo.commit_file("feature.rs", "fn hello() {}", "v1 of feature");
repo.git(&["checkout", "main"]);
let out = repo.run_ok(&["patch", "create", "-t", "Add hello function", "-B", "feature"]);
let id = out
.trim()
.strip_prefix("Created patch ")
.unwrap()
.to_string();
// Review: request changes
repo.run_ok(&[
"patch",
"review",
&id,
"-v",
"request-changes",
"-b",
"Add documentation",
]);
// Inline comment on the code
repo.run_ok(&[
"patch",
"comment",
&id,
"-b",
"Missing doc comment",
"-f",
"feature.rs",
"-l",
"1",
]);
// General comment
repo.run_ok(&["patch", "comment", &id, "-b", "Otherwise looks good"]);
// Revise with updated code
repo.git(&["checkout", "feature"]);
repo.commit_file(
"feature.rs",
"/// Says hello\nfn hello() {}",
"v2: add docs",
);
repo.git(&["checkout", "main"]);
repo.run_ok(&[
"patch",
"revise",
&id,
"-b",
"Added documentation",
]);
// Approve
repo.run_ok(&["patch", "review", &id, "-v", "approve", "-b", "LGTM now"]);
// Merge via git
repo.git(&["checkout", "main"]);
repo.git(&["merge", "feature"]);
// Verify final state — auto-detected as merged
let out = repo.run_ok(&["patch", "show", &id]);
assert!(out.contains("[merged]"));
assert!(out.contains("Added documentation"));
assert!(out.contains("LGTM now"));
assert!(out.contains("(request-changes)"));
assert!(out.contains("(approve)"));
assert!(out.contains("Missing doc comment"));
assert!(out.contains("Otherwise looks good"));
assert!(out.contains("Inline Comments"));
assert!(out.contains("feature.rs"));
}
// ===========================================================================
// T014: init-key CLI command
// ===========================================================================
#[test]
fn test_init_key_creates_key_files() {
// Use a custom HOME so we don't clobber real keys
let tmp_home = tempfile::TempDir::new().unwrap();
let config_dir = tmp_home.path().join(".config").join("git-collab");
// Create a repo for the CLI to run in
let repo_dir = tempfile::TempDir::new().unwrap();
common::git_cmd(repo_dir.path(), &["init", "-b", "main"]);
common::git_cmd(repo_dir.path(), &["config", "user.name", "Alice"]);
common::git_cmd(repo_dir.path(), &["config", "user.email", "alice@example.com"]);
common::git_cmd(repo_dir.path(), &["commit", "--allow-empty", "-m", "init"]);
// Run init-key with overridden HOME
let output = std::process::Command::new(env!("CARGO_BIN_EXE_git-collab"))
.args(["init-key"])
.current_dir(repo_dir.path())
.env("HOME", tmp_home.path())
.env("XDG_CONFIG_HOME", tmp_home.path().join(".config"))
.output()
.expect("failed to run git-collab");
let stdout = String::from_utf8(output.stdout).unwrap();
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(
output.status.success(),
"init-key should succeed: stdout={}, stderr={}",
stdout,
stderr
);
assert!(stdout.contains("Signing key generated"), "should print success message");
assert!(stdout.contains("Public key:"), "should print public key");
// Key files should exist
assert!(config_dir.join("signing-key").exists(), "private key file should exist");
assert!(config_dir.join("signing-key.pub").exists(), "public key file should exist");
// Run init-key again without --force: should fail
let output = std::process::Command::new(env!("CARGO_BIN_EXE_git-collab"))
.args(["init-key"])
.current_dir(repo_dir.path())
.env("HOME", tmp_home.path())
.env("XDG_CONFIG_HOME", tmp_home.path().join(".config"))
.output()
.expect("failed to run git-collab");
assert!(
!output.status.success(),
"init-key without --force should fail when key exists"
);
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(
stderr.contains("already exists") || stderr.contains("--force"),
"error should mention --force, got: {}",
stderr
);
// Run init-key with --force: should succeed
let output = std::process::Command::new(env!("CARGO_BIN_EXE_git-collab"))
.args(["init-key", "--force"])
.current_dir(repo_dir.path())
.env("HOME", tmp_home.path())
.env("XDG_CONFIG_HOME", tmp_home.path().join(".config"))
.output()
.expect("failed to run git-collab");
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(
output.status.success(),
"init-key --force should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(stdout.contains("Signing key generated"));
}
// ===========================================================================
// Issue delete
// ===========================================================================
#[test]
fn test_issue_delete_removes_from_list() {
let repo = TestRepo::new("Alice", "alice@example.com");
let id = repo.issue_open("Bug to delete");
// Verify it shows up
let out = repo.run_ok(&["issue", "list"]);
assert!(out.contains("Bug to delete"));
// Delete it
let out = repo.run_ok(&["issue", "delete", &id]);
assert!(out.contains("Deleted issue"));
// Verify it's gone
let out = repo.run_ok(&["issue", "list", "--all"]);
assert!(!out.contains("Bug to delete"));
}
#[test]
fn test_issue_delete_nonexistent_errors() {
let repo = TestRepo::new("Alice", "alice@example.com");
let err = repo.run_err(&["issue", "delete", "deadbeef"]);
assert!(!err.is_empty());
}
#[test]
fn test_issue_delete_then_show_errors() {
let repo = TestRepo::new("Alice", "alice@example.com");
let id = repo.issue_open("Ephemeral");
repo.run_ok(&["issue", "delete", &id]);
repo.run_err(&["issue", "show", &id]);
}
// ===========================================================================
// Patch delete
// ===========================================================================
#[test]
fn test_patch_delete_removes_from_list() {
let repo = TestRepo::new("Alice", "alice@example.com");
let id = repo.patch_create("Patch to delete");
// Verify it shows up
let out = repo.run_ok(&["patch", "list"]);
assert!(out.contains("Patch to delete"));
// Delete it
let out = repo.run_ok(&["patch", "delete", &id]);
assert!(out.contains("Deleted patch"));
// Verify it's gone
let out = repo.run_ok(&["patch", "list", "--all"]);
assert!(!out.contains("Patch to delete"));
}
#[test]
fn test_patch_delete_nonexistent_errors() {
let repo = TestRepo::new("Alice", "alice@example.com");
let err = repo.run_err(&["patch", "delete", "deadbeef"]);
assert!(!err.is_empty());
}
#[test]
fn test_patch_delete_then_show_errors() {
let repo = TestRepo::new("Alice", "alice@example.com");
let id = repo.patch_create("Ephemeral patch");
repo.run_ok(&["patch", "delete", &id]);
repo.run_err(&["patch", "show", &id]);
}
// ===========================================================================
// Dashboard smoke
// ===========================================================================
#[test]
fn test_dashboard_smoke_launches_and_quits_cleanly() {
let repo = TestRepo::new("Alice", "alice@example.com");
let output = repo.run_dashboard_smoke("q");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"dashboard should exit cleanly\nstdout: {}\nstderr: {}",
stdout,
stderr
);
}
#[test]
fn test_dashboard_smoke_with_seeded_collab_data_exits_cleanly() {
let repo = TestRepo::new("Alice", "alice@example.com");
repo.issue_open("Dashboard issue");
repo.patch_create("Dashboard patch");
let output = repo.run_dashboard_smoke("q");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"dashboard with seeded data should exit cleanly\nstdout: {}\nstderr: {}",
stdout,
stderr
);
}