use std::process::Command;
use assert_cmd::prelude::*;
use jira_cli::output::exit_codes;
use jira_cli::test_support::{config_dir_env_name, write_config};
use tempfile::TempDir;
use wiremock::matchers::{body_partial_json, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn config_fixture() -> &'static str {
r#"
[default]
host = "work.atlassian.net"
email = "me@example.com"
token = "secret-token"
"#
}
fn jira_cmd(dir: &TempDir) -> Command {
let mut cmd = Command::cargo_bin("jira").unwrap();
cmd.env(config_dir_env_name(), dir.path())
.env("NO_COLOR", "1")
.env_remove("JIRA_HOST")
.env_remove("JIRA_EMAIL")
.env_remove("JIRA_TOKEN")
.env_remove("JIRA_PROFILE")
.env_remove("JIRA_READ_ONLY");
cmd
}
#[test]
fn config_show_auto_json_when_piped() {
let dir = TempDir::new().unwrap();
let config_path = write_config(dir.path(), config_fixture()).unwrap();
let output = jira_cmd(&dir).args(["config", "show"]).output().unwrap();
assert!(output.status.success());
assert!(output.stderr.is_empty());
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(json["configPath"], config_path.display().to_string());
assert_eq!(json["host"], "work.atlassian.net");
assert_eq!(json["email"], "me@example.com");
assert_eq!(json["tokenMasked"], "***oken");
}
#[test]
fn config_init_auto_json_when_piped() {
let dir = TempDir::new().unwrap();
let output = jira_cmd(&dir).args(["config", "init"]).output().unwrap();
assert!(output.status.success());
assert!(output.stderr.is_empty());
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(
json["configPath"],
dir.path()
.join("jira")
.join("config.toml")
.display()
.to_string()
);
assert_eq!(
json["tokenInstructions"],
"https://id.atlassian.com/manage-profile/security/api-tokens"
);
assert_eq!(
json["example"]["default"]["host"],
"mycompany.atlassian.net"
);
assert!(json["pathResolution"].as_str().is_some());
assert!(json["recommendedPermissions"].as_str().is_some());
assert_eq!(json["configExists"], false);
}
#[test]
fn init_alias_matches_config_init_json_contract() {
let dir = TempDir::new().unwrap();
let output = jira_cmd(&dir).args(["init"]).output().unwrap();
assert!(output.status.success());
assert!(output.stderr.is_empty());
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(
json["configPath"],
dir.path()
.join("jira")
.join("config.toml")
.display()
.to_string()
);
assert_eq!(
json["tokenInstructions"],
"https://id.atlassian.com/manage-profile/security/api-tokens"
);
}
#[test]
fn config_show_invalid_config_returns_input_exit_code() {
let dir = TempDir::new().unwrap();
let output = jira_cmd(&dir).args(["config", "show"]).output().unwrap();
assert_eq!(output.status.code(), Some(exit_codes::INPUT_ERROR));
assert!(output.stdout.is_empty());
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(stderr.contains("No Jira host configured"));
}
fn error_envelope(stderr: &str) -> serde_json::Value {
let envelope: serde_json::Value = serde_json::from_str(stderr.trim()).unwrap_or_else(|e| {
panic!("the whole of stderr must parse as one JSON error envelope ({e}), got:\n{stderr}")
});
assert!(
envelope.get("error").is_some(),
"envelope needs an `error` key, got: {envelope}"
);
envelope
}
#[test]
fn config_remove_emits_declared_json_contract() {
let dir = TempDir::new().unwrap();
let path = write_config(
dir.path(),
"[default]\nhost = \"first.atlassian.net\"\ntoken = \"tok1\"\n\n\
[profiles.work]\nhost = \"work.atlassian.net\"\ntoken = \"tok2\"\n",
)
.unwrap();
let output = jira_cmd(&dir)
.args(["config", "remove", "work"])
.output()
.unwrap();
assert!(output.status.success(), "removal must succeed");
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap_or_else(|e| {
panic!(
"stdout must be JSON when piped, got {:?}: {e}",
output.stdout
)
});
assert_eq!(json["profile"], "work");
assert_eq!(json["removed"], true);
let keys: Vec<&str> = json
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
assert_eq!(keys, vec!["profile", "removed"], "output must match schema");
let content = std::fs::read_to_string(&path).unwrap();
assert!(!content.contains("work.atlassian.net"), "work must be gone");
assert!(content.contains("first.atlassian.net"), "default preserved");
}
#[test]
fn config_remove_missing_profile_is_not_found() {
let dir = TempDir::new().unwrap();
write_config(
dir.path(),
"[default]\nhost = \"first.atlassian.net\"\ntoken = \"tok1\"\n\n\
[profiles.work]\nhost = \"work.atlassian.net\"\ntoken = \"tok2\"\n",
)
.unwrap();
let output = jira_cmd(&dir)
.args(["config", "remove", "nosuchprofile"])
.output()
.unwrap();
assert_eq!(output.status.code(), Some(exit_codes::NOT_FOUND));
assert!(output.stdout.is_empty(), "no data on stdout for a failure");
let stderr = String::from_utf8(output.stderr).unwrap();
let envelope = error_envelope(&stderr);
assert_eq!(envelope["error"]["kind"], "not_found");
let message = envelope["error"]["message"].as_str().unwrap();
assert!(
message.contains("nosuchprofile"),
"names the profile asked for"
);
assert!(
message.contains("work"),
"lists what is available: {message}"
);
}
#[test]
fn unknown_profile_selection_is_not_found() {
let dir = TempDir::new().unwrap();
write_config(dir.path(), config_fixture()).unwrap();
let output = jira_cmd(&dir)
.args(["--profile", "nosuchprofile", "config", "show"])
.output()
.unwrap();
assert_eq!(output.status.code(), Some(exit_codes::NOT_FOUND));
let stderr = String::from_utf8(output.stderr).unwrap();
let envelope = error_envelope(&stderr);
assert_eq!(envelope["error"]["kind"], "not_found");
}
#[test]
fn unknown_profile_with_no_named_profiles_does_not_render_an_empty_list() {
let dir = TempDir::new().unwrap();
write_config(dir.path(), config_fixture()).unwrap();
let output = jira_cmd(&dir)
.args(["--profile", "nosuchprofile", "config", "show"])
.output()
.unwrap();
let stderr = String::from_utf8(output.stderr).unwrap();
let message = error_envelope(&stderr)["error"]["message"]
.as_str()
.unwrap()
.to_string();
assert!(
!message.trim_end().ends_with("Available:"),
"an empty profile list must not be rendered as a value: {message}"
);
}
#[test]
fn completions_install_powershell_returns_input_error() {
let output = Command::cargo_bin("jira")
.unwrap()
.args(["completions", "powershell", "--install"])
.output()
.unwrap();
assert_eq!(output.status.code(), Some(exit_codes::INPUT_ERROR));
assert!(output.stdout.is_empty());
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(stderr.contains("not supported"));
assert!(stderr.to_lowercase().contains("redirect"));
}
fn run_jira_against(server: &MockServer, args: &[&str]) -> std::process::Output {
let dir = TempDir::new().unwrap();
jira_cmd(&dir)
.args(args)
.env("JIRA_HOST", server.uri())
.env("JIRA_EMAIL", "test@example.com")
.env("JIRA_TOKEN", "test-token")
.output()
.unwrap()
}
#[tokio::test]
async fn myself_emits_the_email_field_it_declares() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/api/3/myself"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"accountId": "user-abc-123",
"displayName": "Test User",
"emailAddress": "test@example.com"
})))
.expect(1)
.mount(&server)
.await;
let output = run_jira_against(&server, &["myself", "--json"]);
assert!(output.status.success());
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(json["accountId"], "user-abc-123");
assert_eq!(json["displayName"], "Test User");
assert_eq!(
json["email"], "test@example.com",
"schema declares `email`, so it must be emitted"
);
}
#[tokio::test]
async fn myself_renders_a_withheld_email_as_null_rather_than_dropping_it() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/api/3/myself"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"accountId": "user-private-456",
"displayName": "Private User"
})))
.expect(1)
.mount(&server)
.await;
let output = run_jira_against(&server, &["myself", "--json"]);
assert!(output.status.success());
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
let obj = json.as_object().unwrap();
assert!(
obj.contains_key("email"),
"a withheld email must still appear as a key, not vanish: {json}"
);
assert!(
json["email"].is_null(),
"a withheld email must be null, never an empty string: {json}"
);
}
#[tokio::test]
async fn issues_update_dispatch_assignee_me_calls_myself_then_puts_account_id() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/api/3/myself"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"accountId": "abc-self-123",
"displayName": "Test User",
"emailAddress": "test@example.com",
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("PUT"))
.and(path("/rest/api/3/issue/PROJ-1"))
.and(body_partial_json(serde_json::json!({
"fields": { "assignee": { "accountId": "abc-self-123" } }
})))
.respond_with(ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
let output = run_jira_against(&server, &["issues", "update", "PROJ-1", "--assignee", "me"]);
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[tokio::test]
async fn issues_update_dispatch_assignee_none_sends_null_in_single_put() {
let server = MockServer::start().await;
Mock::given(method("PUT"))
.and(path("/rest/api/3/issue/PROJ-1"))
.and(body_partial_json(serde_json::json!({
"fields": { "assignee": null }
})))
.respond_with(ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
let output = run_jira_against(
&server,
&["issues", "update", "PROJ-1", "--assignee", "none"],
);
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[tokio::test]
async fn issues_update_dispatch_fix_versions_none_sends_empty_array() {
let server = MockServer::start().await;
Mock::given(method("PUT"))
.and(path("/rest/api/3/issue/PROJ-1"))
.and(body_partial_json(serde_json::json!({
"fields": { "fixVersions": [] }
})))
.respond_with(ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
let output = run_jira_against(
&server,
&["issues", "update", "PROJ-1", "--fix-versions", "none"],
);
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[tokio::test]
async fn issues_update_dispatch_labels_passthrough() {
let server = MockServer::start().await;
Mock::given(method("PUT"))
.and(path("/rest/api/3/issue/PROJ-1"))
.and(body_partial_json(serde_json::json!({
"fields": { "labels": ["backend", "urgent"] }
})))
.respond_with(ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
let output = run_jira_against(
&server,
&[
"issues", "update", "PROJ-1", "--labels", "backend", "--labels", "urgent",
],
);
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[tokio::test]
async fn issues_update_dispatch_combined_flags_send_one_put() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/api/3/myself"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"accountId": "abc-self-123",
"displayName": "Test User",
"emailAddress": "test@example.com",
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("PUT"))
.and(path("/rest/api/3/issue/PROJ-1"))
.and(body_partial_json(serde_json::json!({
"fields": {
"summary": "Updated summary",
"fixVersions": [{ "name": "1.2.0" }],
"labels": ["backend"],
"assignee": { "accountId": "abc-self-123" }
}
})))
.respond_with(ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
let output = run_jira_against(
&server,
&[
"issues",
"update",
"PROJ-1",
"--summary",
"Updated summary",
"--fix-versions",
"1.2.0",
"--labels",
"backend",
"--assignee",
"me",
],
);
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn schema_output_validates_against_clispec_v0_3() {
let dir = TempDir::new().unwrap();
let output = jira_cmd(&dir).args(["schema"]).output().unwrap();
assert!(
output.status.success(),
"jira schema failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let schema_output: serde_json::Value =
serde_json::from_slice(&output.stdout).expect("jira schema must emit valid JSON");
let meta_schema_str = include_str!("fixtures/clispec-v0.3.json");
let meta_schema: serde_json::Value = serde_json::from_str(meta_schema_str)
.expect("bundled clispec v0.3 schema must be valid JSON");
let validator = jsonschema::validator_for(&meta_schema)
.expect("clispec v0.3 JSON Schema must be compilable");
let errors: Vec<String> = validator
.iter_errors(&schema_output)
.map(|e| format!("{e}"))
.collect();
assert!(
errors.is_empty(),
"jira schema output failed clispec v0.3 validation:\n{}",
errors.join("\n")
);
}
#[test]
fn issues_list_help_mentions_fields_flag() {
let output = Command::cargo_bin("jira")
.unwrap()
.args(["issues", "list", "--help"])
.output()
.unwrap();
let help = String::from_utf8_lossy(&output.stdout);
assert!(
help.contains("--fields"),
"issues list --help must mention --fields; got:\n{help}"
);
}
#[test]
fn unrecognized_subcommand_emits_error_envelope() {
let output = Command::cargo_bin("jira")
.unwrap()
.args(["__no_such_subcommand__"])
.output()
.unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
let envelope = error_envelope(&stderr);
assert_eq!(envelope["error"]["kind"], "invalid_input");
let message = envelope["error"]["message"].as_str().unwrap();
assert!(
message.contains("__no_such_subcommand__"),
"envelope must carry clap's own message; got: {message}"
);
}
#[test]
fn help_is_success_output_with_a_silent_stderr() {
let output = Command::cargo_bin("jira")
.unwrap()
.arg("--help")
.output()
.unwrap();
assert_eq!(output.status.code(), Some(exit_codes::SUCCESS));
assert!(
output.stderr.is_empty(),
"--help must not write to stderr, got:\n{}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("Usage:"), "help goes to stdout: {stdout}");
}
#[test]
fn version_is_success_output_with_a_silent_stderr() {
let output = Command::cargo_bin("jira")
.unwrap()
.arg("--version")
.output()
.unwrap();
assert_eq!(output.status.code(), Some(exit_codes::SUCCESS));
assert!(
output.stderr.is_empty(),
"--version must not write to stderr, got:\n{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(String::from_utf8_lossy(&output.stdout).contains("jira "));
}
#[test]
fn machine_mode_stderr_is_only_the_envelope() {
let dir = TempDir::new().unwrap();
let output = jira_cmd(&dir)
.args(["--json", "issues", "show", "PROJ-1"])
.output()
.unwrap();
assert_eq!(output.status.code(), Some(exit_codes::INPUT_ERROR));
let stderr = String::from_utf8(output.stderr).unwrap();
let envelope = error_envelope(&stderr);
assert_eq!(envelope["error"]["kind"], "invalid_input");
assert!(
!stderr.contains("Error:"),
"the prose rendering must not accompany the envelope, got:\n{stderr}"
);
}
#[test]
fn text_mode_stderr_is_only_prose() {
let dir = TempDir::new().unwrap();
let output = jira_cmd(&dir)
.args(["-o", "text", "issues", "show", "PROJ-1"])
.output()
.unwrap();
assert_eq!(output.status.code(), Some(exit_codes::INPUT_ERROR));
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(
stderr.contains("No Jira host configured"),
"text mode explains the failure in prose, got:\n{stderr}"
);
assert!(
!stderr.contains("\"error\""),
"text mode must not emit the JSON envelope, got:\n{stderr}"
);
}
#[test]
fn text_mode_usage_error_keeps_clap_prose() {
let output = Command::cargo_bin("jira")
.unwrap()
.args(["-o", "text", "__no_such_subcommand__"])
.output()
.unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("unrecognized subcommand"), "got:\n{stderr}");
assert!(
!stderr.contains("\"error\""),
"text mode must not emit the JSON envelope, got:\n{stderr}"
);
}
#[test]
fn bulk_transition_without_yes_reports_confirmation_required() {
let dir = TempDir::new().unwrap();
write_config(dir.path(), config_fixture()).unwrap();
let output = jira_cmd(&dir)
.args([
"--json",
"issues",
"bulk-transition",
"--jql",
"project = TST",
"--to",
"Done",
])
.output()
.unwrap();
let stderr = String::from_utf8(output.stderr).unwrap();
let envelope = error_envelope(&stderr);
assert_eq!(
envelope["error"]["kind"], "confirmation_required",
"refusing for want of --yes is not a malformed-input error"
);
assert_eq!(output.status.code(), Some(exit_codes::INPUT_ERROR));
}
fn run_jira_against_read_only(server: &MockServer, args: &[&str]) -> std::process::Output {
let dir = TempDir::new().unwrap();
jira_cmd(&dir)
.args(args)
.env("JIRA_HOST", server.uri())
.env("JIRA_EMAIL", "test@example.com")
.env("JIRA_TOKEN", "test-token")
.env("JIRA_READ_ONLY", "1")
.output()
.unwrap()
}
fn attachment_json(id: &str, filename: &str, size: u64) -> serde_json::Value {
serde_json::json!({
"id": id,
"filename": filename,
"author": { "displayName": "Alice", "accountId": "abc123" },
"created": "2024-01-15T10:00:00.000Z",
"size": size,
"mimeType": "application/pdf",
})
}
fn issue_with_attachments(attachments: serde_json::Value) -> serde_json::Value {
serde_json::json!({ "fields": { "attachment": attachments } })
}
fn schema_command(command: &str) -> serde_json::Value {
let dir = TempDir::new().unwrap();
let output = jira_cmd(&dir).args(["schema"]).output().unwrap();
assert!(output.status.success());
let schema: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
schema["commands"]
.as_array()
.unwrap()
.iter()
.find(|c| c["name"] == command)
.unwrap_or_else(|| panic!("schema must declare command '{command}'"))
.clone()
}
fn assert_json_keys_match_schema(command: &str, actual: &serde_json::Value, extra: &[&str]) {
let schema = schema_command(command);
let fields = schema["output_fields"].as_array().unwrap();
assert_fields_match(command, fields, actual, extra);
}
fn assert_fields_match(
path: &str,
fields: &[serde_json::Value],
actual: &serde_json::Value,
extra: &[&str],
) {
let declared: std::collections::BTreeSet<&str> =
fields.iter().map(|f| f["name"].as_str().unwrap()).collect();
let required: std::collections::BTreeSet<&str> = fields
.iter()
.filter(|f| f["optional"] != serde_json::Value::Bool(true))
.map(|f| f["name"].as_str().unwrap())
.collect();
let mut emitted: std::collections::BTreeSet<&str> = actual
.as_object()
.unwrap_or_else(|| panic!("{path} must be a JSON object; got: {actual}"))
.keys()
.map(String::as_str)
.collect();
emitted.extend(extra.iter().copied());
let undeclared: Vec<&str> = emitted.difference(&declared).copied().collect();
assert!(
undeclared.is_empty(),
"{path} emits {undeclared:?}, which `jira schema` does not declare"
);
let missing: Vec<&str> = required.difference(&emitted).copied().collect();
assert!(
missing.is_empty(),
"`jira schema` declares {missing:?} for {path}, which it does not emit \
(mark the field `optional` if it is genuinely conditional)"
);
for field in fields {
let nested = field["fields"]
.as_array()
.or_else(|| field["items"]["fields"].as_array());
let Some(nested) = nested else {
continue;
};
let name = field["name"].as_str().unwrap();
let value = &actual[name];
if value.is_null() {
continue;
}
let child = format!("{path}.{name}");
match field["type"].as_str() {
Some("array") if field["items"]["type"] == "object" => {
let items = value
.as_array()
.unwrap_or_else(|| panic!("{child} is declared object[]; got: {value}"));
for (i, item) in items.iter().enumerate() {
assert_fields_match(&format!("{child}[{i}]"), nested, item, &[]);
}
}
_ => assert_fields_match(&child, nested, value, &[]),
}
}
}
#[test]
fn issues_attach_help_mentions_file_flag() {
let output = Command::cargo_bin("jira")
.unwrap()
.args(["issues", "attach", "--help"])
.output()
.unwrap();
let help = String::from_utf8_lossy(&output.stdout);
assert!(
help.contains("--file"),
"issues attach --help must mention --file; got:\n{help}"
);
}
#[tokio::test]
async fn issues_attach_missing_required_file_flag_is_rejected() {
let server = MockServer::start().await;
let output = run_jira_against(&server, &["issues", "attach", "PROJ-1"]);
assert_eq!(output.status.code(), Some(exit_codes::INPUT_ERROR));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("--file"),
"missing required --file must be reported; got:\n{stderr}"
);
assert!(server.received_requests().await.unwrap().is_empty());
}
#[tokio::test]
async fn attachment_read_commands_are_allowed_in_read_only_mode() {
let server = MockServer::start().await;
let dest = TempDir::new().unwrap();
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/PROJ-1"))
.respond_with(
ResponseTemplate::new(200).set_body_json(issue_with_attachments(serde_json::json!([]))),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/api/3/attachment/10001"))
.respond_with(ResponseTemplate::new(200).set_body_json(attachment_json(
"10001",
"report.pdf",
4,
)))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/api/3/attachment/content/10001"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"data".to_vec()))
.mount(&server)
.await;
for args in [
&["issues", "attachments", "PROJ-1"][..],
&[
"issues",
"download-attachment",
"10001",
"--dir",
dest.path().to_str().unwrap(),
][..],
] {
let output = run_jira_against_read_only(&server, args);
assert!(
output.status.success(),
"args: {args:?}, stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
}
}
#[tokio::test]
async fn issues_attachments_json_output_matches_schema_contract() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/PROJ-1"))
.respond_with(
ResponseTemplate::new(200).set_body_json(issue_with_attachments(serde_json::json!([
attachment_json("10001", "report.pdf", 2048)
]))),
)
.mount(&server)
.await;
let output = run_jira_against(&server, &["issues", "attachments", "PROJ-1"]);
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_json_keys_match_schema("issues attachments", &json["attachments"][0], &[]);
let schema = schema_command("issues attachments");
let author = schema["output_fields"]
.as_array()
.unwrap()
.iter()
.find(|f| f["name"] == "author")
.unwrap();
assert_eq!(author["type"], "string");
assert!(
json["attachments"][0]["author"].is_string(),
"schema declares 'author' as type string but the actual value is: {}",
json["attachments"][0]["author"]
);
}
#[tokio::test]
async fn issues_attach_json_field_names_match_schema_contract() {
let server = MockServer::start().await;
let dir = TempDir::new().unwrap();
let file_path = dir.path().join("diagram.png");
std::fs::write(&file_path, b"png-bytes").unwrap();
Mock::given(method("POST"))
.and(path("/rest/api/3/issue/PROJ-1/attachments"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
attachment_json("10005", "diagram.png", 9)
])))
.mount(&server)
.await;
let output = run_jira_against(
&server,
&[
"issues",
"attach",
"PROJ-1",
"--file",
file_path.to_str().unwrap(),
],
);
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_json_keys_match_schema("issues attach", &json["attachments"][0], &["issue"]);
}
#[tokio::test]
async fn issues_download_attachment_json_field_names_match_schema_contract() {
let server = MockServer::start().await;
let dest = TempDir::new().unwrap();
Mock::given(method("GET"))
.and(path("/rest/api/3/attachment/10001"))
.respond_with(ResponseTemplate::new(200).set_body_json(attachment_json(
"10001",
"report.pdf",
4,
)))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/api/3/attachment/content/10001"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"data".to_vec()))
.mount(&server)
.await;
let output = run_jira_against(
&server,
&[
"issues",
"download-attachment",
"10001",
"--dir",
dest.path().to_str().unwrap(),
],
);
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_json_keys_match_schema("issues download-attachment", &json, &[]);
}
#[tokio::test]
async fn issues_delete_attachment_json_field_names_match_schema_contract() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/rest/api/3/attachment/10001"))
.respond_with(ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
let output = run_jira_against(&server, &["issues", "delete-attachment", "10001"]);
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_json_keys_match_schema("issues delete-attachment", &json, &[]);
}
#[tokio::test]
async fn issues_download_attachment_with_non_numeric_id_is_rejected_before_any_request() {
let server = MockServer::start().await;
let output = run_jira_against(&server, &["issues", "download-attachment", "not-a-number"]);
assert_eq!(output.status.code(), Some(exit_codes::INPUT_ERROR));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("not-a-number"), "stderr: {stderr}");
assert!(server.received_requests().await.unwrap().is_empty());
}
#[tokio::test]
async fn issues_download_attachment_without_force_exits_conflict_and_reports_kind() {
let server = MockServer::start().await;
let dest = TempDir::new().unwrap();
let target = dest.path().join("report.pdf");
std::fs::write(&target, b"original bytes").unwrap();
Mock::given(method("GET"))
.and(path("/rest/api/3/attachment/10001"))
.respond_with(ResponseTemplate::new(200).set_body_json(attachment_json(
"10001",
"report.pdf",
4,
)))
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/api/3/attachment/content/10001"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"data".to_vec()))
.expect(0)
.mount(&server)
.await;
let output = run_jira_against(
&server,
&[
"issues",
"download-attachment",
"10001",
"--dir",
dest.path().to_str().unwrap(),
],
);
assert_eq!(output.status.code(), Some(exit_codes::CONFLICT));
assert!(
output.stdout.is_empty(),
"stdout must stay clean on refusal; got: {}",
String::from_utf8_lossy(&output.stdout)
);
let stderr = String::from_utf8_lossy(&output.stderr);
let last_line = stderr
.lines()
.rev()
.find(|l| !l.trim().is_empty())
.expect("stderr must not be empty");
let envelope: serde_json::Value = serde_json::from_str(last_line)
.unwrap_or_else(|_| panic!("last stderr line must be a JSON error envelope: {last_line}"));
assert_eq!(envelope["error"]["kind"], "conflict");
let message = envelope["error"]["message"].as_str().unwrap();
assert!(
message.contains(&target.display().to_string()),
"conflict message must name the path; got: {message}"
);
assert_eq!(std::fs::read(&target).unwrap(), b"original bytes");
}
#[tokio::test]
async fn issues_download_attachment_force_overwrites_via_cli() {
let server = MockServer::start().await;
let dest = TempDir::new().unwrap();
let target = dest.path().join("report.pdf");
std::fs::write(&target, b"original bytes").unwrap();
Mock::given(method("GET"))
.and(path("/rest/api/3/attachment/10001"))
.respond_with(ResponseTemplate::new(200).set_body_json(attachment_json(
"10001",
"report.pdf",
4,
)))
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/api/3/attachment/content/10001"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"new data".to_vec()))
.expect(1)
.mount(&server)
.await;
let output = run_jira_against(
&server,
&[
"issues",
"download-attachment",
"10001",
"--dir",
dest.path().to_str().unwrap(),
"--force",
],
);
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(std::fs::read(&target).unwrap(), b"new data");
}
#[tokio::test]
async fn issues_attachments_text_table_renders_sizes_at_unit_boundaries() {
let cases = [
(1023u64, "1023 B"),
(1024, "1.0 KB"),
(1024 * 1024, "1.0 MB"),
(1024 * 1024 * 1024, "1.0 GB"),
];
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/PROJ-1"))
.respond_with(
ResponseTemplate::new(200).set_body_json(issue_with_attachments(
cases
.iter()
.enumerate()
.map(|(i, (size, _))| attachment_json(&i.to_string(), "f.bin", *size))
.collect(),
)),
)
.mount(&server)
.await;
let output = run_jira_against(
&server,
&["issues", "attachments", "PROJ-1", "--output", "text"],
);
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
for (size, rendered) in cases {
assert!(
stdout.contains(rendered),
"{size} bytes must render as {rendered:?}; stdout:\n{stdout}"
);
}
}
#[tokio::test]
async fn issues_attachments_degrades_absent_author_and_mime_type() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/PROJ-1"))
.respond_with(
ResponseTemplate::new(200).set_body_json(issue_with_attachments(serde_json::json!([{
"id": "10001",
"filename": "notes.txt",
"created": "2024-01-15T10:00:00.000Z",
"size": 10,
}]))),
)
.mount(&server)
.await;
let output = run_jira_against(&server, &["issues", "attachments", "PROJ-1"]);
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
let attachment = &json["attachments"][0];
assert!(
attachment["mimeType"].is_null(),
"mimeType must stay null when Jira omits it, got: {}",
attachment["mimeType"]
);
assert!(
attachment["author"].is_null(),
"author must stay null when Jira omits it, so an unattributed upload is \
distinguishable from a user whose display name is literally '-'; got: {}",
attachment["author"]
);
let output = run_jira_against(
&server,
&["issues", "attachments", "PROJ-1", "--output", "text"],
);
let stdout = String::from_utf8_lossy(&output.stdout);
let row = stdout
.lines()
.find(|line| line.contains("notes.txt"))
.unwrap_or_else(|| panic!("attachment row must be present, got:\n{stdout}"));
let cells: Vec<&str> = row.split_whitespace().collect();
assert_eq!(
cells.get(4),
Some(&"-"),
"Type column must show '-' when mimeType is absent; row: {row}"
);
assert_eq!(
cells.get(5),
Some(&"-"),
"Author column must show '-' when author is absent; row: {row}"
);
}
fn search_page(issue: serde_json::Value) -> serde_json::Value {
serde_json::json!({ "issues": [issue], "isLast": true })
}
fn full_issue() -> serde_json::Value {
serde_json::json!({
"id": "10001",
"key": "PROJ-1",
"self": "https://test.atlassian.net/rest/api/3/issue/PROJ-1",
"fields": {
"summary": "A summary",
"status": { "name": "To Do" },
"assignee": { "displayName": "Alice", "accountId": "abc123" },
"reporter": { "displayName": "Bob", "accountId": "def456" },
"priority": { "name": "Medium" },
"issuetype": { "name": "Bug" },
"description": {
"type": "doc", "version": 1,
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "Body"}]}]
},
"labels": ["backend"],
"components": [{ "id": "1", "name": "api", "description": "API" }],
"fixVersions": [{ "id": "2", "name": "1.0" }],
"versions": [{ "id": "3", "name": "0.9" }],
"created": "2024-01-15T10:00:00.000Z",
"updated": "2024-01-20T15:30:00.000Z",
"comment": {
"comments": [{
"id": "10100",
"author": { "displayName": "Alice", "accountId": "abc123" },
"body": { "type": "doc", "version": 1, "content": [] },
"created": "2024-01-21T09:00:00.000Z",
"updated": "2024-01-21T09:05:00.000Z"
}],
"total": 1
},
"issuelinks": [{
"id": "20001",
"type": { "id": "10", "name": "Blocks", "inward": "is blocked by", "outward": "blocks" },
"outwardIssue": { "key": "PROJ-2", "fields": { "summary": "Other", "status": { "name": "Done" } } }
}]
}
})
}
fn bare_issue() -> serde_json::Value {
serde_json::json!({
"id": "10002",
"key": "PROJ-2",
"fields": {
"summary": "A summary",
"status": { "name": "To Do" },
"issuetype": { "name": "Task" }
}
})
}
#[tokio::test]
async fn issue_summary_commands_emit_exactly_the_fields_they_declare() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/rest/api/3/search/jql"))
.respond_with(ResponseTemplate::new(200).set_body_json(search_page(full_issue())))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/api/3/myself"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"accountId": "abc123", "displayName": "Alice"
})))
.mount(&server)
.await;
for (command, args) in [
("issues list", &["issues", "list", "--json"][..]),
("issues mine", &["issues", "mine", "--json"][..]),
("search", &["search", "project = PROJ", "--json"][..]),
] {
let output = run_jira_against(&server, args);
assert!(
output.status.success(),
"{command} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_json_keys_match_schema(command, &json["items"][0], &[]);
}
}
#[tokio::test]
async fn issues_show_emits_exactly_the_fields_it_declares() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/PROJ-1"))
.respond_with(ResponseTemplate::new(200).set_body_json(full_issue()))
.mount(&server)
.await;
let output = run_jira_against(&server, &["issues", "show", "PROJ-1", "--json"]);
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_json_keys_match_schema("issues show", &json, &[]);
}
#[tokio::test]
async fn issues_log_work_emits_exactly_the_fields_it_declares() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/rest/api/3/issue/PROJ-1/worklog"))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"id": "30001",
"author": { "displayName": "Alice", "accountId": "abc123" },
"timeSpent": "1h 30m",
"timeSpentSeconds": 5400,
"started": "2024-01-15T10:00:00.000+0000",
"created": "2024-01-15T10:01:00.000+0000"
})))
.mount(&server)
.await;
let output = run_jira_against(
&server,
&["issues", "log-work", "PROJ-1", "--time", "1h 30m", "--json"],
);
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_json_keys_match_schema("issues log-work", &json, &[]);
}
#[tokio::test]
async fn bulk_commands_emit_exactly_the_fields_they_declare() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/rest/api/3/search/jql"))
.respond_with(ResponseTemplate::new(200).set_body_json(search_page(full_issue())))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/PROJ-1/transitions"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"transitions": [{ "id": "31", "name": "Done", "to": { "name": "Done" } }]
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/rest/api/3/issue/PROJ-1/transitions"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
Mock::given(method("PUT"))
.and(path("/rest/api/3/issue/PROJ-1/assignee"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
for (command, args) in [
(
"issues bulk-transition",
&[
"issues",
"bulk-transition",
"--jql",
"project = PROJ",
"--to",
"Done",
"--yes",
"--json",
][..],
),
(
"issues bulk-assign",
&[
"issues",
"bulk-assign",
"--jql",
"project = PROJ",
"--assignee",
"abc123",
"--yes",
"--json",
][..],
),
] {
let output = run_jira_against(&server, args);
assert!(
output.status.success(),
"{command} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_json_keys_match_schema(command, &json, &[]);
}
}
#[tokio::test]
async fn projects_versions_emits_exactly_the_fields_it_declares() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/api/3/project/PROJ/versions"))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!([{
"id": "10000",
"name": "1.0",
"description": "First release",
"released": true,
"archived": false,
"releaseDate": "2024-02-01"
}])),
)
.mount(&server)
.await;
let output = run_jira_against(&server, &["projects", "versions", "PROJ", "--json"]);
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_json_keys_match_schema("projects versions", &json["versions"][0], &[]);
}
#[tokio::test]
async fn sprints_list_emits_exactly_the_fields_it_declares() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/agile/1.0/board"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"values": [{ "id": 1, "name": "Board One", "type": "scrum" }],
"isLast": true,
"total": 1
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/agile/1.0/board/1/sprint"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"values": [{
"id": 5,
"name": "Sprint 5",
"state": "closed",
"startDate": "2024-01-01T00:00:00.000Z",
"endDate": "2024-01-14T00:00:00.000Z",
"completeDate": "2024-01-14T18:00:00.000Z"
}],
"isLast": true
})))
.mount(&server)
.await;
let output = run_jira_against(&server, &["sprints", "list", "--json"]);
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_json_keys_match_schema("sprints list", &json["sprints"][0], &[]);
}
#[test]
fn init_emits_exactly_the_fields_it_declares() {
for (command, args) in [
("init", &["init", "--json"][..]),
("config init", &["config", "init", "--json"][..]),
] {
let dir = TempDir::new().unwrap();
let output = jira_cmd(&dir).args(args).output().unwrap();
assert!(output.status.success());
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_json_keys_match_schema(command, &json, &[]);
}
}
#[tokio::test]
async fn issues_assign_emits_the_same_keys_whether_it_assigns_or_unassigns() {
let server = MockServer::start().await;
Mock::given(method("PUT"))
.and(path("/rest/api/3/issue/PROJ-1/assignee"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let assigned = run_jira_against(
&server,
&[
"issues",
"assign",
"PROJ-1",
"--assignee",
"abc123",
"--json",
],
);
assert!(
assigned.status.success(),
"stderr: {}",
String::from_utf8_lossy(&assigned.stderr)
);
let assigned: serde_json::Value = serde_json::from_slice(&assigned.stdout).unwrap();
assert_json_keys_match_schema("issues assign", &assigned, &[]);
assert_eq!(assigned["accountId"], "abc123");
let unassigned = run_jira_against(
&server,
&["issues", "assign", "PROJ-1", "--assignee", "none", "--json"],
);
assert!(
unassigned.status.success(),
"stderr: {}",
String::from_utf8_lossy(&unassigned.stderr)
);
let unassigned: serde_json::Value = serde_json::from_slice(&unassigned.stdout).unwrap();
assert_json_keys_match_schema("issues assign", &unassigned, &[]);
assert!(
unassigned["accountId"].is_null(),
"an unassigned issue is reported by a null accountId, got: {}",
unassigned["accountId"]
);
}
#[tokio::test]
async fn absent_issue_fields_are_null_in_json_and_dashes_only_in_the_table() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/rest/api/3/search/jql"))
.respond_with(ResponseTemplate::new(200).set_body_json(search_page(bare_issue())))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/PROJ-2"))
.respond_with(ResponseTemplate::new(200).set_body_json(bare_issue()))
.mount(&server)
.await;
let output = run_jira_against(&server, &["issues", "list", "--json"]);
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
let item = &json["items"][0];
assert!(
item["assignee"].is_null(),
"an unassigned issue must report a null assignee, got: {}",
item["assignee"]
);
assert!(
item["priority"].is_null(),
"an issue with no priority must report null, got: {}",
item["priority"]
);
let output = run_jira_against(&server, &["issues", "show", "PROJ-2", "--json"]);
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
for field in ["assignee", "priority", "reporter", "description"] {
assert!(
json[field].is_null(),
"issues show must report an absent {field} as null, got: {}",
json[field]
);
}
let output = run_jira_against(&server, &["issues", "show", "PROJ-2", "--output", "text"]);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("Assignee: -"),
"table must still show a dash for an unassigned issue; got:\n{stdout}"
);
}
#[tokio::test]
async fn issues_comments_emits_exactly_the_fields_it_declares() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/PROJ-1"))
.respond_with(ResponseTemplate::new(200).set_body_json(full_issue()))
.mount(&server)
.await;
let output = run_jira_against(&server, &["issues", "comments", "PROJ-1", "--json"]);
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_json_keys_match_schema("issues comments", &json["comments"][0], &[]);
}
#[tokio::test]
async fn issues_create_emits_exactly_the_fields_it_declares() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/rest/api/3/issue"))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"id": "10001", "key": "PROJ-1",
"self": "https://test.atlassian.net/rest/api/3/issue/10001"
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/agile/1.0/sprint/5"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": 5, "name": "Sprint 5", "state": "active"
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/rest/agile/1.0/sprint/5/issue"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let bare = run_jira_against(
&server,
&[
"issues",
"create",
"--project",
"PROJ",
"--type",
"Task",
"--summary",
"S",
"--json",
],
);
assert!(
bare.status.success(),
"stderr: {}",
String::from_utf8_lossy(&bare.stderr)
);
let bare: serde_json::Value = serde_json::from_slice(&bare.stdout).unwrap();
assert_json_keys_match_schema("issues create", &bare, &[]);
for optional in ["parent", "sprintId", "sprintName"] {
assert!(
bare.get(optional).is_none(),
"an unadorned create must not invent a {optional} key: {bare}"
);
}
let enriched = run_jira_against(
&server,
&[
"issues",
"create",
"--project",
"PROJ",
"--type",
"Task",
"--summary",
"S",
"--parent",
"PROJ-9",
"--sprint",
"5",
"--json",
],
);
assert!(
enriched.status.success(),
"stderr: {}",
String::from_utf8_lossy(&enriched.stderr)
);
let enriched: serde_json::Value = serde_json::from_slice(&enriched.stdout).unwrap();
assert_json_keys_match_schema("issues create", &enriched, &[]);
for optional in ["parent", "sprintId", "sprintName"] {
assert!(
enriched.get(optional).is_some(),
"--parent/--sprint must produce a {optional} key: {enriched}"
);
}
}
#[tokio::test]
async fn issue_write_commands_emit_exactly_the_fields_they_declare() {
let server = MockServer::start().await;
Mock::given(method("PUT"))
.and(path("/rest/api/3/issue/PROJ-1"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/rest/api/3/issue/PROJ-1/comment"))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"id": "10100",
"author": { "displayName": "Alice", "accountId": "abc123" },
"body": { "type": "doc", "version": 1, "content": [] },
"created": "2024-01-21T09:00:00.000Z",
"updated": "2024-01-21T09:00:00.000Z"
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/PROJ-1/transitions"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"transitions": [{
"id": "31", "name": "Done",
"to": { "name": "Done", "statusCategory": { "key": "done", "name": "Done" } }
}]
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/rest/api/3/issue/PROJ-1/transitions"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/agile/1.0/sprint/5"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": 5, "name": "Sprint 5", "state": "active"
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/rest/agile/1.0/sprint/5/issue"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
for (command, args) in [
(
"issues update",
&["issues", "update", "PROJ-1", "--summary", "New", "--json"][..],
),
(
"issues comment",
&["issues", "comment", "PROJ-1", "--body", "Hello", "--json"][..],
),
(
"issues transition",
&["issues", "transition", "PROJ-1", "--to", "Done", "--json"][..],
),
(
"issues move",
&["issues", "move", "PROJ-1", "--sprint", "5", "--json"][..],
),
] {
let output = run_jira_against(&server, args);
assert!(
output.status.success(),
"{command} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_json_keys_match_schema(command, &json, &[]);
}
}
#[tokio::test]
async fn issue_link_commands_emit_exactly_the_fields_they_declare() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/api/3/issueLinkType"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"issueLinkTypes": [{
"id": "10000", "name": "Blocks",
"inward": "is blocked by", "outward": "blocks"
}]
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/rest/api/3/issueLink"))
.respond_with(ResponseTemplate::new(201))
.mount(&server)
.await;
Mock::given(method("DELETE"))
.and(path("/rest/api/3/issueLink/20001"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let output = run_jira_against(&server, &["issues", "link-types", "--json"]);
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_json_keys_match_schema("issues link-types", &json[0], &[]);
for (command, args) in [
(
"issues link",
&[
"issues",
"link",
"PROJ-1",
"--to",
"PROJ-2",
"--link-type",
"Blocks",
"--json",
][..],
),
(
"issues unlink",
&["issues", "unlink", "20001", "--json"][..],
),
] {
let output = run_jira_against(&server, args);
assert!(
output.status.success(),
"{command} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_json_keys_match_schema(command, &json, &[]);
}
}
#[tokio::test]
async fn issues_list_transitions_emits_exactly_the_fields_it_declares() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/PROJ-1/transitions"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"transitions": [{
"id": "31", "name": "Done",
"to": { "name": "Done", "statusCategory": { "key": "done", "name": "Done" } }
}]
})))
.mount(&server)
.await;
let output = run_jira_against(&server, &["issues", "list-transitions", "PROJ-1", "--json"]);
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_json_keys_match_schema("issues list-transitions", &json[0], &[]);
}
#[tokio::test]
async fn read_only_listings_emit_exactly_the_fields_they_declare() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/api/3/project/search"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"values": [{ "id": "10000", "key": "PROJ", "name": "Project", "projectTypeKey": "software" }],
"startAt": 0, "maxResults": 50, "total": 1, "isLast": true
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/api/3/project/PROJ"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "10000", "key": "PROJ", "name": "Project", "projectTypeKey": "software"
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/api/3/project/PROJ/components"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
{ "id": "1", "name": "api", "description": "API layer" }
])))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/api/3/user/search"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
{ "accountId": "abc123", "displayName": "Alice", "emailAddress": "alice@example.com" }
])))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/agile/1.0/board"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"values": [{ "id": 1, "name": "Board One", "type": "scrum" }],
"startAt": 0, "maxResults": 50, "total": 1, "isLast": true
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/api/3/field"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
{ "id": "summary", "name": "Summary", "custom": false,
"schema": { "type": "string" } }
])))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/api/3/myself"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"accountId": "abc123",
"displayName": "Alice",
"emailAddress": "alice@example.com"
})))
.mount(&server)
.await;
for (command, args, envelope) in [
(
"projects list",
&["projects", "list", "--json"][..],
"projects",
),
(
"projects show",
&["projects", "show", "PROJ", "--json"][..],
"",
),
(
"projects components",
&["projects", "components", "PROJ", "--json"][..],
"components",
),
(
"users search",
&["users", "search", "alice", "--json"][..],
"users",
),
("boards list", &["boards", "list", "--json"][..], "boards"),
("fields list", &["fields", "list", "--json"][..], "fields"),
("myself", &["myself", "--json"][..], ""),
] {
let output = run_jira_against(&server, args);
assert!(
output.status.success(),
"{command} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
let element = if envelope.is_empty() {
&json
} else {
&json[envelope][0]
};
assert_json_keys_match_schema(command, element, &[]);
}
}
#[test]
fn config_commands_emit_exactly_the_fields_they_declare() {
let dir = TempDir::new().unwrap();
write_config(
dir.path(),
"[default]\nhost = \"work.atlassian.net\"\nemail = \"me@example.com\"\ntoken = \"tok\"\n\n\
[profiles.work]\nhost = \"work.atlassian.net\"\ntoken = \"tok2\"\n",
)
.unwrap();
let output = jira_cmd(&dir).args(["config", "show"]).output().unwrap();
assert!(output.status.success());
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_json_keys_match_schema("config show", &json, &[]);
let output = jira_cmd(&dir)
.args(["config", "remove", "work"])
.output()
.unwrap();
assert!(output.status.success());
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_json_keys_match_schema("config remove", &json, &[]);
}
const COMMANDS_WITH_A_CONFORMANCE_TEST: &[&str] = &[
"boards list",
"capabilities",
"config init",
"config remove",
"config show",
"fields list",
"init",
"issues assign",
"issues attach",
"issues attachments",
"issues bulk-assign",
"issues bulk-transition",
"issues comment",
"issues comments",
"issues create",
"issues delete-attachment",
"issues download-attachment",
"issues link",
"issues link-types",
"issues list",
"issues list-transitions",
"issues log-work",
"issues mine",
"issues move",
"issues show",
"issues transition",
"issues unlink",
"issues update",
"myself",
"projects components",
"projects list",
"projects show",
"projects versions",
"search",
"sprints list",
"users search",
];
#[test]
fn capabilities_json_matches_declared_output_fields() {
let dir = TempDir::new().unwrap();
let schema_output = jira_cmd(&dir).args(["schema"]).output().unwrap();
let schema: serde_json::Value = serde_json::from_slice(&schema_output.stdout).unwrap();
let fields = schema["commands"]
.as_array()
.unwrap()
.iter()
.find(|command| command["name"] == "capabilities")
.unwrap()["output_fields"]
.as_array()
.unwrap();
let output = jira_cmd(&dir)
.args(["--output", "json", "capabilities"])
.output()
.unwrap();
assert!(output.status.success());
let actual: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_fields_match("capabilities", fields, &actual, &[]);
}
#[test]
fn every_command_declaring_output_fields_has_a_conformance_test() {
let dir = TempDir::new().unwrap();
let output = jira_cmd(&dir).args(["schema"]).output().unwrap();
let schema: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
let declaring: std::collections::BTreeSet<&str> = schema["commands"]
.as_array()
.unwrap()
.iter()
.filter(|c| c["output_fields"].as_array().is_some_and(|f| !f.is_empty()))
.map(|c| c["name"].as_str().unwrap())
.collect();
let checked: std::collections::BTreeSet<&str> =
COMMANDS_WITH_A_CONFORMANCE_TEST.iter().copied().collect();
let unchecked: Vec<&str> = declaring.difference(&checked).copied().collect();
assert!(
unchecked.is_empty(),
"these commands declare output_fields with nothing asserting the binary \
agrees: {unchecked:?}"
);
let stale: Vec<&str> = checked.difference(&declaring).copied().collect();
assert!(
stale.is_empty(),
"these commands are listed as checked but no longer declare output_fields: {stale:?}"
);
}
const OBJECTS_WITHOUT_A_DECLARED_SHAPE: &[(&str, &str)] = &[
(
"config init.example.profiles",
"keyed by profile name, chosen by the user",
),
(
"init.example.profiles",
"keyed by profile name, chosen by the user",
),
];
#[test]
fn every_declared_object_either_has_a_shape_or_a_stated_reason() {
let dir = TempDir::new().unwrap();
let output = jira_cmd(&dir).args(["schema"]).output().unwrap();
let schema: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
fn collect(path: &str, fields: &[serde_json::Value], out: &mut Vec<String>) {
for f in fields {
let name = f["name"].as_str().unwrap();
let child = format!("{path}.{name}");
let is_object = f["type"] == "object";
let nested = f["fields"]
.as_array()
.or_else(|| f["items"]["fields"].as_array());
match nested {
Some(nested) => collect(&child, nested, out),
None if is_object => out.push(child),
None => {}
}
}
}
let mut opaque = Vec::new();
for command in schema["commands"].as_array().unwrap() {
let Some(fields) = command["output_fields"].as_array() else {
continue;
};
collect(command["name"].as_str().unwrap(), fields, &mut opaque);
}
opaque.sort();
let excused: std::collections::BTreeSet<&str> = OBJECTS_WITHOUT_A_DECLARED_SHAPE
.iter()
.map(|(name, _)| *name)
.collect();
let found: std::collections::BTreeSet<&str> = opaque.iter().map(String::as_str).collect();
let undeclared: Vec<&str> = found.difference(&excused).copied().collect();
assert!(
undeclared.is_empty(),
"these object fields declare no nested shape, so nothing checks their keys: \
{undeclared:?} (add `fields`, or list it in OBJECTS_WITHOUT_A_DECLARED_SHAPE \
with the reason its keys are not fixed)"
);
let stale: Vec<&str> = excused.difference(&found).copied().collect();
assert!(
stale.is_empty(),
"these are excused from declaring a shape but no longer need to be: {stale:?}"
);
}
const JIRA_WRITE_INVOCATIONS: &[(&str, &[&str])] = &[
(
"issues create",
&[
"issues",
"create",
"--project",
"PROJ",
"--summary",
"Summary",
],
),
("issues update", &["issues", "update", "PROJ-1"]),
(
"issues move",
&["issues", "move", "PROJ-1", "--sprint", "5"],
),
(
"issues comment",
&["issues", "comment", "PROJ-1", "--body", "text"],
),
(
"issues transition",
&["issues", "transition", "PROJ-1", "--to", "Done"],
),
(
"issues assign",
&["issues", "assign", "PROJ-1", "--assignee", "abc123"],
),
(
"issues link",
&["issues", "link", "PROJ-1", "--to", "PROJ-2"],
),
("issues unlink", &["issues", "unlink", "10001"]),
(
"issues log-work",
&["issues", "log-work", "PROJ-1", "--time", "1h"],
),
(
"issues attach",
&["issues", "attach", "PROJ-1", "--file", "irrelevant.bin"],
),
(
"issues delete-attachment",
&["issues", "delete-attachment", "10001"],
),
(
"issues bulk-transition",
&[
"issues",
"bulk-transition",
"--jql",
"project = PROJ",
"--to",
"Done",
],
),
(
"issues bulk-assign",
&[
"issues",
"bulk-assign",
"--jql",
"project = PROJ",
"--assignee",
"abc123",
],
),
];
#[test]
fn every_environment_variable_the_cli_reads_is_declared_in_the_schema() {
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut read_by_code = std::collections::BTreeSet::new();
let mut stack = vec![src];
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(&dir).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
stack.push(path);
continue;
}
if path.extension().is_none_or(|e| e != "rs") {
continue;
}
let text = std::fs::read_to_string(&path).unwrap();
for (i, _) in text.match_indices("\"JIRA_") {
let rest = &text[i + 1..];
let Some(end) = rest.find('"') else { continue };
let name = &rest[..end];
if name
.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
{
read_by_code.insert(name.to_string());
}
}
}
}
assert!(
read_by_code.contains("JIRA_HOST"),
"the scan found no known variable, so it is broken rather than clean"
);
let dir = TempDir::new().unwrap();
let output = jira_cmd(&dir).args(["schema"]).output().unwrap();
let schema: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
fn declared(value: &serde_json::Value, out: &mut std::collections::BTreeSet<String>) {
match value {
serde_json::Value::Object(map) => {
for (key, child) in map {
if let (true, Some(s)) =
(matches!(key.as_str(), "name" | "env"), child.as_str())
{
out.insert(s.to_string());
}
declared(child, out);
}
}
serde_json::Value::Array(items) => items.iter().for_each(|i| declared(i, out)),
_ => {}
}
}
let mut in_schema = std::collections::BTreeSet::new();
declared(&schema, &mut in_schema);
let undeclared: Vec<&String> = read_by_code.difference(&in_schema).collect();
assert!(
undeclared.is_empty(),
"these environment variables change how the CLI behaves but `jira schema` \
never names them: {undeclared:?}"
);
}
fn schema_read_only_blocked() -> Vec<String> {
let dir = TempDir::new().unwrap();
let output = jira_cmd(&dir).args(["schema"]).output().unwrap();
let schema: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
schema["read_only"]["blocked_commands"]
.as_array()
.expect("schema must declare which commands read-only mode blocks")
.iter()
.map(|c| c.as_str().unwrap().to_string())
.collect()
}
const MUTATING_WITHOUT_WRITING_TO_JIRA: &[(&str, &str)] = &[
("init", "writes the local config file"),
("config init", "writes the local config file"),
("config remove", "edits the local config file"),
(
"issues download-attachment",
"reads from Jira, writes the bytes to a local path",
),
];
#[tokio::test]
async fn every_command_the_schema_says_is_blocked_really_is() {
let server = MockServer::start().await;
let declared = schema_read_only_blocked();
let covered: std::collections::BTreeSet<&str> = JIRA_WRITE_INVOCATIONS
.iter()
.map(|(name, _)| *name)
.collect();
let missing: Vec<&String> = declared
.iter()
.filter(|c| !covered.contains(c.as_str()))
.collect();
assert!(
missing.is_empty(),
"the schema claims these are blocked but nothing here runs them: {missing:?}"
);
let unclaimed: Vec<&str> = covered
.iter()
.filter(|c| !declared.iter().any(|d| d == *c))
.copied()
.collect();
assert!(
unclaimed.is_empty(),
"these are exercised here but the schema does not list them as blocked, \
so an agent reading the contract would not know: {unclaimed:?}"
);
for (command, args) in JIRA_WRITE_INVOCATIONS {
let output = run_jira_against_read_only(&server, args);
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(
output.status.code(),
Some(exit_codes::INPUT_ERROR),
"{command} was not refused; stderr: {stderr}"
);
let envelope = error_envelope(&stderr);
assert_eq!(
envelope["error"]["kind"], "invalid_input",
"{command} must be refused through the standard error envelope"
);
let message = envelope["error"]["message"].as_str().unwrap_or_default();
assert!(
message.contains("read-only"),
"{command} failed for some other reason than the guard: {message}"
);
}
let requests = server.received_requests().await.unwrap();
let reached: Vec<String> = requests
.iter()
.map(|r| format!("{} {}", r.method, r.url.path()))
.collect();
assert!(
reached.is_empty(),
"read-only mode let these requests through: {reached:?}"
);
}
#[test]
fn config_writing_commands_still_work_in_read_only_mode() {
let dir = TempDir::new().unwrap();
let output = jira_cmd(&dir)
.args(["--host", "example.atlassian.net", "config", "init"])
.env("JIRA_READ_ONLY", "1")
.env("JIRA_EMAIL", "me@example.com")
.env("JIRA_TOKEN", "token")
.output()
.unwrap();
assert!(
output.status.success(),
"config init was refused under read-only; stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let output = jira_cmd(&dir)
.args(["init"])
.env("JIRA_READ_ONLY", "1")
.output()
.unwrap();
assert!(
output.status.success(),
"init was refused under read-only; stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn every_mutating_command_is_either_guarded_or_excused() {
let dir = TempDir::new().unwrap();
let output = jira_cmd(&dir).args(["schema"]).output().unwrap();
let schema: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
let mutating: std::collections::BTreeSet<&str> = schema["commands"]
.as_array()
.unwrap()
.iter()
.filter(|c| c["mutating"] == true)
.map(|c| c["name"].as_str().unwrap())
.collect();
let blocked = schema_read_only_blocked();
let guarded: std::collections::BTreeSet<&str> = blocked.iter().map(String::as_str).collect();
let excused: std::collections::BTreeSet<&str> = MUTATING_WITHOUT_WRITING_TO_JIRA
.iter()
.map(|(name, _)| *name)
.collect();
let unaccounted: Vec<&str> = mutating
.iter()
.filter(|c| !guarded.contains(*c) && !excused.contains(*c))
.copied()
.collect();
assert!(
unaccounted.is_empty(),
"these commands are declared mutating but nothing says whether read-only \
mode stops them: {unaccounted:?} (add them to READ_ONLY_BLOCKED_COMMANDS \
and the guard in main.rs, or a reason to MUTATING_WITHOUT_WRITING_TO_JIRA)"
);
let stale: Vec<&str> = guarded
.union(&excused)
.filter(|c| !mutating.contains(*c))
.copied()
.collect();
assert!(
stale.is_empty(),
"these are listed here but the schema no longer declares them mutating: {stale:?}"
);
}
#[test]
fn no_em_or_en_dashes_in_published_files() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let mut scanned = 0usize;
let mut offenders: Vec<String> = Vec::new();
let mut stack = vec![root.join("src"), root.join("tests")];
let mut files: Vec<std::path::PathBuf> =
vec![root.join("README.md"), root.join("CHANGELOG.md")];
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(&dir).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
stack.push(path);
} else if path.extension().is_some_and(|e| e == "rs") {
files.push(path);
}
}
}
for path in files {
let text = std::fs::read_to_string(&path).unwrap();
scanned += 1;
for (number, line) in text.lines().enumerate() {
if line.contains('\u{2014}') || line.contains('\u{2013}') {
let name = path.strip_prefix(root).unwrap_or(&path).display();
offenders.push(format!("{name}:{}: {}", number + 1, line.trim()));
}
}
}
assert!(
scanned > 5,
"the scan read {scanned} files, so it is broken rather than clean"
);
assert!(
offenders.is_empty(),
"use a hyphen, a comma, or two sentences instead:\n{}",
offenders.join("\n")
);
}
fn oversized_search_page() -> serde_json::Value {
let issues: Vec<serde_json::Value> = (1..=600)
.map(|n| {
serde_json::json!({
"id": n.to_string(),
"key": format!("PROJ-{n}"),
"fields": {
"summary": "A summary long enough that six hundred of them add up to far more than any pipe buffer holds",
"status": { "name": "To Do" },
"issuetype": { "name": "Task" }
}
})
})
.collect();
serde_json::json!({ "issues": issues, "isLast": true })
}
#[tokio::test]
async fn a_downstream_that_stops_reading_does_not_panic_the_writer() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/rest/api/3/search/jql"))
.respond_with(ResponseTemplate::new(200).set_body_json(oversized_search_page()))
.mount(&server)
.await;
let args = ["issues", "list", "--limit", "600", "--json"];
let whole = run_jira_against(&server, &args);
assert!(
whole.status.success(),
"stderr: {}",
String::from_utf8_lossy(&whole.stderr)
);
assert!(
whole.stdout.len() > 128 * 1024,
"the fixture renders {} bytes, too few to fill a pipe buffer",
whole.stdout.len()
);
let dir = TempDir::new().unwrap();
let mut child = jira_cmd(&dir)
.args(args)
.env("JIRA_HOST", server.uri())
.env("JIRA_EMAIL", "test@example.com")
.env("JIRA_TOKEN", "test-token")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
let mut stdout = child.stdout.take().unwrap();
let mut first = [0u8; 40];
std::io::Read::read_exact(&mut stdout, &mut first).unwrap();
drop(stdout);
let output = child.wait_with_output().unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!stderr.contains("panicked"),
"a reader that stops early must not panic the writer:\n{stderr}"
);
assert_ne!(
output.status.code(),
Some(101),
"101 is a panic, and is not one of the declared exit codes"
);
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
assert_eq!(
output.status.signal(),
Some(13),
"the writer must die of SIGPIPE the way any other member of a pipeline does"
);
}
}