#![allow(clippy::expect_used, clippy::unwrap_used)]
use predicates::prelude::*;
use wiremock::matchers::{body_json, method, path, query_param};
use wiremock::{Mock, ResponseTemplate};
mod harness;
use harness::{Harness, fixture};
#[tokio::test]
async fn create_sends_the_queue_summary_and_returns_the_new_key() {
let harness = Harness::new().await;
Mock::given(method("POST"))
.and(path("/v3/issues/"))
.and(body_json(serde_json::json!({
"queue": {"key": "PROJ"},
"summary": "Retry uploads on 5xx",
"assignee": "ilubenets",
})))
.respond_with(ResponseTemplate::new(201).set_body_json(fixture("issue.json")))
.mount(&harness.server)
.await;
harness
.run(&[
"issue",
"create",
"-q",
"PROJ",
"-s",
"Retry uploads on 5xx",
"--assignee",
"ilubenets",
])
.assert()
.success()
.stdout(predicate::str::starts_with("PROJ-1 "));
}
#[tokio::test]
async fn dry_run_sends_nothing_and_prints_the_body() {
let harness = Harness::new().await;
harness
.run(&[
"issue",
"create",
"-q",
"PROJ",
"-s",
"nothing to see",
"--dry-run",
])
.assert()
.success()
.stderr(predicate::str::contains(
"dry run: would create an issue in PROJ",
))
.stderr(predicate::str::contains("\"summary\": \"nothing to see\""));
assert!(
harness
.server
.received_requests()
.await
.expect("recorded")
.is_empty()
);
}
#[tokio::test]
async fn every_write_announces_the_profile_and_organisation() {
let harness = Harness::new().await;
Mock::given(method("POST"))
.and(path("/v3/issues/PROJ-1/comments"))
.respond_with(
ResponseTemplate::new(201)
.set_body_json(serde_json::json!({"id": 205, "text": "done"})),
)
.mount(&harness.server)
.await;
harness
.run(&["issue", "comment", "PROJ-1", "done"])
.assert()
.success()
.stderr(predicate::str::contains("→ profile=test org=12345"))
.stdout(predicate::str::contains("PROJ-1 comment 205"));
}
#[tokio::test]
async fn update_types_values_by_reading_them_as_json() {
let harness = Harness::new().await;
Mock::given(method("PATCH"))
.and(path("/v3/issues/PROJ-1"))
.and(body_json(serde_json::json!({
"storyPoints": 3,
"summary": "still a string",
})))
.respond_with(ResponseTemplate::new(200).set_body_json(fixture("issue.json")))
.mount(&harness.server)
.await;
harness
.run(&[
"issue",
"update",
"PROJ-1",
"--set",
"storyPoints=3",
"--set",
"summary=still a string",
])
.assert()
.success();
}
#[tokio::test]
async fn an_update_that_changes_nothing_is_refused() {
let harness = Harness::new().await;
harness
.run(&["issue", "update", "PROJ-1"])
.assert()
.code(2)
.stderr(predicate::str::contains("nothing to change"));
}
#[tokio::test]
async fn a_malformed_assignment_is_refused_before_anything_is_sent() {
let harness = Harness::new().await;
harness
.run(&["issue", "update", "PROJ-1", "--set", "storyPoints"])
.assert()
.code(2)
.stderr(predicate::str::contains("expected key=value"));
assert!(
harness
.server
.received_requests()
.await
.expect("recorded")
.is_empty()
);
}
#[tokio::test]
async fn transition_without_an_id_lists_what_is_available() {
let harness = Harness::new().await;
Mock::given(method("GET"))
.and(path("/v3/issues/PROJ-1/transitions"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
{
"id": "close",
"display": "Close",
"to": {"id": "3", "key": "closed", "display": "Closed"}
},
{
"id": "start_progress",
"display": "Start progress",
"to": {"id": "2", "key": "inProgress", "display": "In Progress"}
}
])))
.mount(&harness.server)
.await;
let output = harness
.run(&["issue", "transition", "PROJ-1"])
.assert()
.success();
let stdout = String::from_utf8(output.get_output().stdout.clone()).expect("utf-8");
assert!(stdout.contains("close"));
assert!(stdout.contains("→ Closed"));
assert!(stdout.ends_with("shown 2 of 2 for PROJ-1\n"));
}
#[tokio::test]
async fn transition_with_an_id_executes_it() {
let harness = Harness::new().await;
Mock::given(method("POST"))
.and(path("/v3/issues/PROJ-1/transitions/close/_execute"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
.mount(&harness.server)
.await;
harness
.run(&["issue", "transition", "PROJ-1", "close"])
.assert()
.success()
.stdout(predicate::str::contains("PROJ-1 close"));
}
#[tokio::test]
async fn a_transition_carries_the_fields_it_is_given() {
let harness = Harness::new().await;
Mock::given(method("POST"))
.and(path("/v3/issues/PROJ-1/transitions/close/_execute"))
.and(body_json(serde_json::json!({
"resolution": "wontFix",
"comment": "not this quarter",
})))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
.expect(1)
.mount(&harness.server)
.await;
harness
.run(&[
"issue",
"transition",
"PROJ-1",
"close",
"-r",
"wontFix",
"--set",
"comment=not this quarter",
])
.assert()
.success();
}
#[tokio::test]
async fn a_transition_refused_for_want_of_a_field_says_how_to_supply_one() {
let harness = Harness::new().await;
Mock::given(method("POST"))
.and(path("/v3/issues/PROJ-1/transitions/close/_execute"))
.respond_with(ResponseTemplate::new(422).set_body_json(serde_json::json!({
"errorMessages": ["Вы должны указать значения для полей Резолюция."],
"statusCode": 422,
})))
.mount(&harness.server)
.await;
harness
.run(&["issue", "transition", "PROJ-1", "close"])
.assert()
.code(5)
.stderr(predicate::str::contains("Резолюция"))
.stderr(predicate::str::contains("--resolution"));
}
#[tokio::test]
async fn a_transition_that_was_given_fields_is_not_told_to_pass_fields() {
let harness = Harness::new().await;
Mock::given(method("POST"))
.and(path("/v3/issues/PROJ-1/transitions/close/_execute"))
.respond_with(ResponseTemplate::new(422).set_body_json(serde_json::json!({
"errorMessages": ["резолюция nope не существует."],
"statusCode": 422,
})))
.mount(&harness.server)
.await;
harness
.run(&["issue", "transition", "PROJ-1", "close", "-r", "nope"])
.assert()
.code(5)
.stderr(predicate::str::contains("--resolution").not());
}
#[tokio::test]
async fn a_failed_write_is_not_retried() {
let harness = Harness::new().await;
Mock::given(method("POST"))
.and(path("/v3/issues/"))
.respond_with(ResponseTemplate::new(500))
.expect(1)
.mount(&harness.server)
.await;
harness
.run(&["issue", "create", "-q", "PROJ", "-s", "once"])
.assert()
.code(5);
}
#[tokio::test]
async fn changing_several_issues_without_yes_exits_two_and_sends_nothing() {
let harness = Harness::new().await;
harness
.run(&[
"issue",
"update",
"PROJ-1",
"PROJ-4",
"--set",
"storyPoints=3",
])
.assert()
.code(2)
.stderr(predicate::str::contains("without --yes"))
.stderr(predicate::str::contains("PROJ-1, PROJ-4"));
assert!(
harness
.server
.received_requests()
.await
.expect("recorded")
.is_empty()
);
}
#[tokio::test]
async fn several_issues_with_yes_are_one_request_and_a_tally() {
let harness = Harness::new().await;
Mock::given(method("POST"))
.and(path("/v3/bulkchange/_update"))
.and(body_json(serde_json::json!({
"issues": ["PROJ-1", "PROJ-4"],
"values": {"storyPoints": 3},
})))
.respond_with(ResponseTemplate::new(201).set_body_json(fixture("bulkchange_created.json")))
.expect(1)
.mount(&harness.server)
.await;
Mock::given(method("GET"))
.and(path("/v3/bulkchange/6a92d90773c59502bc8e028a"))
.respond_with(ResponseTemplate::new(200).set_body_json(fixture("bulkchange_complete.json")))
.mount(&harness.server)
.await;
let output = harness
.run(&[
"issue",
"update",
"PROJ-1",
"PROJ-4",
"--set",
"storyPoints=3",
"--yes",
])
.assert()
.success();
let stdout = String::from_utf8(output.get_output().stdout.clone()).expect("utf-8");
assert!(stdout.contains("changed 2 of 2"), "{stdout}");
assert!(
stdout.contains("bulkchange 6a92d90773c59502bc8e028a"),
"{stdout}"
);
assert!(!stdout.contains("PROJ-1"), "{stdout}");
}
#[tokio::test]
async fn a_bulk_change_that_failed_names_each_issue_and_why() {
let harness = Harness::new().await;
Mock::given(method("POST"))
.and(path("/v3/bulkchange/_update"))
.respond_with(ResponseTemplate::new(201).set_body_json(fixture("bulkchange_created.json")))
.mount(&harness.server)
.await;
Mock::given(method("GET"))
.and(path("/v3/bulkchange/6a92d90773c59502bc8e028a"))
.respond_with(ResponseTemplate::new(200).set_body_json(fixture("bulkchange_failed.json")))
.mount(&harness.server)
.await;
Mock::given(method("GET"))
.and(path("/v3/bulkchange/6a92d9387d41a060a2b5e6d9/issues"))
.respond_with(ResponseTemplate::new(200).set_body_json(fixture("bulkchange_issues.json")))
.mount(&harness.server)
.await;
let output = harness
.run(&[
"issue",
"update",
"PROJ-1",
"PROJ-4",
"--set",
"priority=nonexistent",
"--yes",
])
.assert()
.code(5);
let stdout = String::from_utf8(output.get_output().stdout.clone()).expect("utf-8");
assert!(stdout.contains("changed 1 of 2"), "{stdout}");
assert!(stdout.contains("priority: "), "{stdout}");
assert!(stdout.contains("PROJ-2"), "{stdout}");
assert!(!stdout.contains("PROJ-1 "), "{stdout}");
}
#[tokio::test]
async fn a_key_that_does_not_exist_stops_the_whole_change() {
let harness = Harness::new().await;
Mock::given(method("POST"))
.and(path("/v3/bulkchange/_update"))
.respond_with(ResponseTemplate::new(422).set_body_json(serde_json::json!({
"errors": {"issues": "задачи [PROJ-999] не существуют"},
"statusCode": 422,
})))
.mount(&harness.server)
.await;
harness
.run(&[
"issue",
"update",
"PROJ-1",
"PROJ-999",
"--set",
"storyPoints=3",
"--yes",
])
.assert()
.code(5)
.stderr(predicate::str::contains("PROJ-999"));
}
#[tokio::test]
async fn no_wait_returns_the_id_without_claiming_the_work_is_done() {
let harness = Harness::new().await;
Mock::given(method("POST"))
.and(path("/v3/bulkchange/_update"))
.respond_with(ResponseTemplate::new(201).set_body_json(fixture("bulkchange_created.json")))
.expect(1)
.mount(&harness.server)
.await;
let output = harness
.run(&[
"issue",
"update",
"PROJ-1",
"PROJ-4",
"--set",
"storyPoints=3",
"--yes",
"--no-wait",
])
.assert()
.success();
let stdout = String::from_utf8(output.get_output().stdout.clone()).expect("utf-8");
assert!(
stdout.contains("bulkchange 6a92d90773c59502bc8e028a"),
"{stdout}"
);
assert!(!stdout.contains("changed 2 of 2"), "{stdout}");
}
#[tokio::test]
async fn keys_in_two_organisations_go_one_at_a_time() {
let harness = Harness::new().await;
harness.add_profile("other", "98765");
for key in ["PROJ-1", "PROJ-4"] {
Mock::given(method("PATCH"))
.and(path(format!("/v3/issues/{key}")))
.and(body_json(serde_json::json!({"storyPoints": 3})))
.respond_with(ResponseTemplate::new(200).set_body_json(fixture("issue.json")))
.expect(1)
.mount(&harness.server)
.await;
}
let output = harness
.run(&[
"issue",
"update",
"test/PROJ-1",
"other/PROJ-4",
"--set",
"storyPoints=3",
"--yes",
])
.assert()
.success();
let stdout = String::from_utf8(output.get_output().stdout.clone()).expect("utf-8");
assert!(stdout.contains("changed 2 of 2"), "{stdout}");
}
#[tokio::test]
async fn a_dry_run_over_several_issues_lists_them_all() {
let harness = Harness::new().await;
harness
.run(&[
"issue",
"update",
"PROJ-1",
"PROJ-4",
"--set",
"storyPoints=3",
"--dry-run",
])
.assert()
.success()
.stderr(predicate::str::contains("would update PROJ-1, PROJ-4"));
assert!(
harness
.server
.received_requests()
.await
.expect("recorded")
.is_empty()
);
}
#[tokio::test]
async fn a_malformed_key_stops_the_run_before_anything_is_sent() {
let harness = Harness::new().await;
harness
.run(&["issue", "update", "PROJ-1", "/", "--set", "x=1", "--yes"])
.assert()
.code(2);
assert!(
harness
.server
.received_requests()
.await
.expect("recorded")
.is_empty()
);
}
#[tokio::test]
async fn no_write_verb_reaches_the_network_under_dry_run() {
let file = tempfile::NamedTempFile::new().expect("temp file");
let path = file.path().to_string_lossy().into_owned();
let writes: Vec<Vec<&str>> = vec![
vec!["issue", "create", "-q", "PROJ", "-s", "title"],
vec!["issue", "update", "PROJ-1", "--set", "storyPoints=3"],
vec!["issue", "comment", "PROJ-1", "text"],
vec!["issue", "transition", "PROJ-1", "close"],
vec!["issue", "move", "PROJ-1", "--to", "OPS"],
vec!["attachment", "upload", "PROJ-1", &path],
];
for args in writes {
let harness = Harness::new().await;
let mut with_flag = args.clone();
with_flag.push("--dry-run");
harness.run(&with_flag).assert().success();
assert!(
harness
.server
.received_requests()
.await
.expect("recorded")
.is_empty(),
"`ytcli {}` sent a request under --dry-run",
args.join(" ")
);
}
}
#[tokio::test]
async fn moving_one_issue_still_needs_yes() {
let harness = Harness::new().await;
harness
.run(&["issue", "move", "PROJ-1", "--to", "OPS"])
.assert()
.code(2)
.stderr(predicate::str::contains("cannot be undone"));
assert!(
harness.server.received_requests().await.unwrap().is_empty(),
"a refused move must send nothing"
);
}
#[tokio::test]
async fn a_move_reports_the_key_the_issue_now_has() {
let harness = Harness::new().await;
Mock::given(method("POST"))
.and(path("/v3/issues/PROJ-1/_move"))
.and(query_param("queue", "OPS"))
.and(query_param("moveAllFields", "false"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"key": "OPS-17",
"summary": "Attachments are lost on move",
"queue": {"key": "OPS"}
})))
.mount(&harness.server)
.await;
let output = harness
.run(&["issue", "move", "PROJ-1", "--to", "OPS", "--yes"])
.assert()
.success();
let stdout = String::from_utf8(output.get_output().stdout.clone()).expect("utf-8");
assert_eq!(stdout, "PROJ-1 → OPS-17\n");
}
#[tokio::test]
async fn keep_fields_is_passed_through_to_tracker() {
let harness = Harness::new().await;
Mock::given(method("POST"))
.and(path("/v3/issues/PROJ-1/_move"))
.and(query_param("moveAllFields", "true"))
.and(query_param("initialStatus", "true"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"key": "OPS-17",
"summary": "Attachments are lost on move"
})))
.mount(&harness.server)
.await;
harness
.run(&[
"issue",
"move",
"PROJ-1",
"--to",
"OPS",
"--keep-fields",
"--initial-status",
"--yes",
])
.assert()
.success();
}
#[tokio::test]
async fn several_issues_take_one_transition_in_one_request() {
let harness = Harness::new().await;
Mock::given(method("POST"))
.and(path("/v3/bulkchange/_transition"))
.and(body_json(serde_json::json!({
"issues": ["PROJ-1", "PROJ-4"],
"transition": "close",
"values": {"resolution": "fixed"},
})))
.respond_with(ResponseTemplate::new(201).set_body_json(fixture("bulkchange_created.json")))
.expect(1)
.mount(&harness.server)
.await;
Mock::given(method("GET"))
.and(path("/v3/bulkchange/6a92d90773c59502bc8e028a"))
.respond_with(ResponseTemplate::new(200).set_body_json(fixture("bulkchange_complete.json")))
.mount(&harness.server)
.await;
let output = harness
.run(&[
"issue",
"transition",
"PROJ-1",
"PROJ-4",
"--to",
"close",
"-r",
"fixed",
"--yes",
])
.assert()
.success();
let stdout = String::from_utf8(output.get_output().stdout.clone()).expect("utf-8");
assert!(stdout.contains("changed 2 of 2"), "{stdout}");
}
#[tokio::test]
async fn several_issues_without_to_are_refused_and_send_nothing() {
let harness = Harness::new().await;
harness
.run(&["issue", "transition", "PROJ-1", "PROJ-4", "close", "--yes"])
.assert()
.code(2)
.stderr(predicate::str::contains("--to"));
assert!(
harness
.server
.received_requests()
.await
.expect("recorded")
.is_empty()
);
}
#[tokio::test]
async fn several_issues_move_in_one_request() {
let harness = Harness::new().await;
Mock::given(method("POST"))
.and(path("/v3/bulkchange/_move"))
.and(body_json(serde_json::json!({
"issues": ["PROJ-1", "PROJ-4"],
"queue": "OPS",
"moveAllFields": false,
"initialStatus": false,
})))
.respond_with(ResponseTemplate::new(201).set_body_json(fixture("bulkchange_created.json")))
.expect(1)
.mount(&harness.server)
.await;
Mock::given(method("GET"))
.and(path("/v3/bulkchange/6a92d90773c59502bc8e028a"))
.respond_with(ResponseTemplate::new(200).set_body_json(fixture("bulkchange_complete.json")))
.mount(&harness.server)
.await;
let output = harness
.run(&["issue", "move", "PROJ-1", "PROJ-4", "--to", "OPS", "--yes"])
.assert()
.success();
let stdout = String::from_utf8(output.get_output().stdout.clone()).expect("utf-8");
assert!(stdout.contains("changed 2 of 2"), "{stdout}");
}
#[tokio::test]
async fn moving_several_issues_without_yes_names_all_of_them() {
let harness = Harness::new().await;
harness
.run(&["issue", "move", "PROJ-1", "PROJ-4", "--to", "OPS"])
.assert()
.code(2)
.stderr(predicate::str::contains("PROJ-1, PROJ-4"));
assert!(
harness
.server
.received_requests()
.await
.expect("recorded")
.is_empty()
);
}
#[tokio::test]
async fn a_target_status_is_resolved_to_the_transition_that_reaches_it() {
let harness = Harness::new().await;
Mock::given(method("POST"))
.and(path("/v3/issues/PROJ-1/transitions/closed/_execute"))
.respond_with(ResponseTemplate::new(404))
.expect(1)
.mount(&harness.server)
.await;
Mock::given(method("GET"))
.and(path("/v3/issues/PROJ-1/transitions"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
{"id": "close", "display": "Close", "to": {"key": "closed", "display": "Closed"}}
])))
.expect(1)
.mount(&harness.server)
.await;
Mock::given(method("POST"))
.and(path("/v3/issues/PROJ-1/transitions/close/_execute"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
.expect(1)
.mount(&harness.server)
.await;
let output = harness
.run(&["issue", "transition", "PROJ-1", "closed"])
.assert()
.success();
let stdout = String::from_utf8(output.get_output().stdout.clone()).expect("utf-8");
assert!(stdout.contains("PROJ-1 close"), "{stdout}");
}
#[tokio::test]
async fn a_transition_that_matches_nothing_is_refused_once() {
let harness = Harness::new().await;
Mock::given(method("POST"))
.and(path("/v3/issues/PROJ-1/transitions/nonsense/_execute"))
.respond_with(ResponseTemplate::new(404))
.expect(1)
.mount(&harness.server)
.await;
Mock::given(method("GET"))
.and(path("/v3/issues/PROJ-1/transitions"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
{"id": "close", "display": "Close", "to": {"key": "closed", "display": "Closed"}}
])))
.mount(&harness.server)
.await;
harness
.run(&["issue", "transition", "PROJ-1", "nonsense"])
.assert()
.code(4);
}
#[tokio::test]
async fn a_description_can_come_from_a_file() {
let harness = Harness::new().await;
let file = harness.config_path().with_file_name("body.md");
std::fs::write(&file, "## Steps\n\n1. Attach a file\n").expect("write");
Mock::given(method("POST"))
.and(path("/v3/issues/"))
.and(body_json(serde_json::json!({
"queue": {"key": "PROJ"},
"summary": "from a file",
"description": "## Steps\n\n1. Attach a file\n",
})))
.respond_with(ResponseTemplate::new(201).set_body_json(fixture("issue.json")))
.expect(1)
.mount(&harness.server)
.await;
harness
.run(&[
"issue",
"create",
"-q",
"PROJ",
"-s",
"from a file",
"--description-file",
file.to_str().expect("utf-8 path"),
])
.assert()
.success();
}
#[tokio::test]
async fn a_description_given_twice_is_refused_and_sends_nothing() {
let harness = Harness::new().await;
harness
.run(&[
"issue",
"create",
"-q",
"PROJ",
"-s",
"twice",
"-d",
"inline",
"--description-file",
"/nonexistent",
])
.assert()
.failure();
assert!(
harness
.server
.received_requests()
.await
.expect("recorded")
.is_empty()
);
}
#[tokio::test]
async fn update_replaces_the_description_from_a_file() {
let harness = Harness::new().await;
let file = harness.config_path().with_file_name("new-body.md");
std::fs::write(&file, "rewritten\n").expect("write");
Mock::given(method("PATCH"))
.and(path("/v3/issues/PROJ-1"))
.and(body_json(serde_json::json!({"description": "rewritten\n"})))
.respond_with(ResponseTemplate::new(200).set_body_json(fixture("issue.json")))
.expect(1)
.mount(&harness.server)
.await;
harness
.run(&[
"issue",
"update",
"PROJ-1",
"--description-file",
file.to_str().expect("utf-8 path"),
])
.assert()
.success();
}