use std::process::Output;
use crate::common::{SOURCE_BOUNDARIES, Sandbox, SourceBoundary, stderr, stdout};
use crate::fixtures::{
ROWS, dataset, document, github_projects_recording, linear_recording, qualified,
};
use serde_json::{Value, json};
fn host_at(boundary: SourceBoundary) -> Sandbox {
let sandbox = Sandbox::new();
let mut block = dataset();
block["capabilities"] = json!({"max_page_size": 50});
sandbox.project_document(&document(
&json!({"work": boundary.source(ROWS[0].plugin, block)}),
));
sandbox
}
fn run(sandbox: &Sandbox, arguments: &[&str]) -> Output {
sandbox
.command()
.args(arguments)
.assert()
.get_output()
.clone()
}
fn refused(output: &Output, problem: &str, next: &str) {
assert_ne!(
output.status.code(),
Some(0),
"this must not succeed:\n{}",
stdout(output)
);
let complaint = stderr(output);
assert!(
complaint.contains(problem),
"the message does not name the problem ({problem}):\n{complaint}"
);
assert!(
complaint.contains(next),
"the message suggests no next action ({next}):\n{complaint}"
);
}
fn token_over(sandbox: &Sandbox, streams: &Value) -> String {
let minted: Value = serde_json::from_str(&stdout(&run(
sandbox,
&["task", "list", "--limit", "1", "--json"],
)))
.expect("--json emits a response");
let carried = minted["next"]
.as_str()
.expect("one page of four leaves more");
let decoded: Value = serde_json::from_slice(&unhex(carried)).expect("a token holds JSON");
let document = serde_json::to_string(&json!({
"query": decoded["query"].clone(),
"streams": streams,
}))
.expect("a resume document renders");
document
.as_bytes()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
fn unhex(raw: &str) -> Vec<u8> {
raw.as_bytes()
.chunks(2)
.map(|pair| {
u8::from_str_radix(std::str::from_utf8(pair).expect("hex is ascii"), 16)
.expect("a token is hex")
})
.collect()
}
#[test]
fn a_source_name_nothing_configures_is_refused_with_the_names_that_exist() {
for boundary in SOURCE_BOUNDARIES {
let output = run(
&host_at(boundary),
&["task", "list", "--source", "elsewhere"],
);
refused(&output, "elsewhere", "sources list");
assert_eq!(output.status.code(), Some(1));
}
}
#[test]
fn a_configuration_that_will_not_parse_is_refused_where_it_is_written() {
let sandbox = Sandbox::new();
sandbox.project_document("sources:\n work:\n plugin: [this is not a plugin name\n");
let output = run(&sandbox, &["task", "list"]);
refused(&output, "not valid YAML", "correct the syntax");
}
#[test]
fn a_setting_no_plugin_answers_to_is_refused_by_the_key_that_names_it() {
let sandbox = Sandbox::new();
sandbox.project_document(&document(
&json!({"work": {"plugin": "jira", "config": {}}}),
));
let output = run(&sandbox, &["task", "list"]);
refused(&output, "sources.work.plugin", "use one of");
}
#[test]
fn an_id_that_names_nothing_is_refused_and_says_where_to_look() {
for boundary in SOURCE_BOUNDARIES {
let sandbox = host_at(boundary);
let missing = run(&sandbox, &["task", "show", &qualified("work", "NOPE")]);
refused(&missing, "no task with that id", "task list");
assert_eq!(missing.status.code(), Some(1));
let no_project = run(&sandbox, &["project", "show", &qualified("work", "NOPE")]);
refused(&no_project, "no project with that id", "project list");
let unqualified = run(&sandbox, &["task", "show", "T-1"]);
refused(&unqualified, "is not a qualified id", "sources list");
let elsewhere = run(&sandbox, &["task", "show", "elsewhere:T-1"]);
refused(&elsewhere, "elsewhere", "sources list");
}
}
#[test]
fn a_source_that_cannot_answer_exits_four_and_names_itself() {
for boundary in SOURCE_BOUNDARIES {
let sandbox = Sandbox::new();
sandbox.project_document(&document(
&json!({"gone": boundary.source("linear", json!({}))}),
));
let output = run(&sandbox, &["task", "list"]);
assert_eq!(output.status.code(), Some(4));
refused(&output, "gone", "--allow-partial");
}
}
#[test]
fn a_page_token_this_engine_did_not_write_is_refused_rather_than_restarting_the_walk() {
for boundary in SOURCE_BOUNDARIES {
let output = run(
&host_at(boundary),
&["task", "list", "--page", "not-a-token"],
);
refused(
&output,
"not a page token this engine writes",
"previous page",
);
assert_eq!(output.status.code(), Some(1));
}
}
#[test]
fn a_token_carrying_a_field_this_engine_does_not_write_is_refused_rather_than_defaulted() {
for boundary in SOURCE_BOUNDARIES {
let sandbox = host_at(boundary);
let minted: Value = serde_json::from_str(&stdout(&run(
&sandbox,
&["task", "list", "--limit", "1", "--json"],
)))
.expect("--json emits a response");
let query = serde_json::from_slice::<Value>(&unhex(
minted["next"]
.as_str()
.expect("one page of four leaves more"),
))
.expect("a token holds JSON")["query"]
.clone();
for document in [
json!({
"query": query,
"streams": [{"source": "work", "stream": "items", "cursorr": "1"}],
}),
json!({
"query": query,
"streamz": [],
"streams": [{"source": "work", "stream": "items"}],
}),
] {
let token: String = serde_json::to_string(&document)
.expect("a resume document renders")
.as_bytes()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect();
let output = run(
&sandbox,
&["task", "list", "--limit", "1", "--page", &token],
);
assert_eq!(
output.status.code(),
Some(1),
"{document} must be refused:\n{}",
stdout(&output)
);
refused(&output, "unknown field", "previous page");
}
}
}
#[test]
fn a_well_shaped_token_this_configuration_cannot_honour_is_refused_for_what_it_says() {
for boundary in SOURCE_BOUNDARIES {
let sandbox = host_at(boundary);
for (streams, problem) in [
(
json!([{"source": "elsewhere", "stream": "items"}]),
"does not have",
),
(
json!([{"source": "work", "stream": "items", "skip": 999}]),
"serves at most",
),
(
json!([
{"source": "work", "stream": "items"},
{"source": "work", "stream": "items", "skip": 1},
]),
"two places to resume",
),
(
json!([{"source": "work", "stream": "tasks"}]),
"this command does not read",
),
] {
let token = token_over(&sandbox, &streams);
let output = run(&sandbox, &["task", "list", "--page", &token]);
assert_eq!(
output.status.code(),
Some(1),
"{streams} must be refused:\n{}",
stdout(&output)
);
refused(&output, problem, "the same configuration");
}
}
}
#[test]
fn a_token_owing_the_next_row_to_a_stream_it_does_not_resume_is_refused() {
for boundary in SOURCE_BOUNDARIES {
let sandbox = host_at(boundary);
let minted: Value = serde_json::from_str(&stdout(&run(
&sandbox,
&["task", "list", "--limit", "1", "--json"],
)))
.expect("--json emits a response");
let carried = minted["next"]
.as_str()
.expect("one page of four leaves more");
let mut document: Value =
serde_json::from_slice(&unhex(carried)).expect("a token holds JSON");
document["owed"] = json!({"source": "elsewhere", "stream": "items"});
let forged: String = serde_json::to_string(&document)
.expect("a resume document renders")
.as_bytes()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect();
let output = run(&sandbox, &["task", "list", "--page", &forged]);
assert_eq!(
output.status.code(),
Some(1),
"a token owing a stream it does not resume must be refused:\n{}",
stdout(&output)
);
refused(&output, "does not resume", "the same configuration");
}
}
#[test]
fn a_token_from_one_query_is_refused_by_another_rather_than_resuming_it_somewhere_else() {
for boundary in SOURCE_BOUNDARIES {
let sandbox = host_at(boundary);
let minted: Value = serde_json::from_str(&stdout(&run(
&sandbox,
&["task", "list", "--label", "bug", "--limit", "1", "--json"],
)))
.expect("--json emits a response");
let token = minted["next"].as_str().expect("two tasks carry that label");
let same = run(
&sandbox,
&[
"task", "list", "--label", "bug", "--limit", "1", "--page", token,
],
);
assert_eq!(
same.status.code(),
Some(0),
"the walk it came from must still resume:\n{}",
stderr(&same)
);
for arguments in [
vec!["task", "list", "--label", "core", "--limit", "1"],
vec!["task", "list", "--limit", "1"],
vec![
"task", "list", "--label", "bug", "--status", "todo", "--limit", "1",
],
vec![
"task", "list", "--label", "bug", "--search", "alpha", "--limit", "1",
],
vec!["project", "list", "--limit", "1"],
vec!["label", "list", "--limit", "1"],
] {
let mut with_token = arguments.clone();
with_token.extend(["--page", token]);
let output = run(&sandbox, &with_token);
assert_eq!(
output.status.code(),
Some(1),
"`{}` must not resume a token another query minted:\n{}",
arguments.join(" "),
stdout(&output)
);
refused(&output, "written by a different query", "drop `--page`");
}
}
}
#[test]
fn a_page_of_no_rows_is_refused_as_the_typing_mistake_it_is() {
for boundary in SOURCE_BOUNDARIES {
let output = run(&host_at(boundary), &["task", "list", "--limit", "0"]);
assert_eq!(output.status.code(), Some(2), "{}", stderr(&output));
assert!(
stderr(&output).contains("invalid value '0' for '--limit"),
"{}",
stderr(&output)
);
}
}
#[test]
fn a_source_name_that_could_not_name_anything_is_refused_for_being_unusable() {
for boundary in SOURCE_BOUNDARIES {
let output = run(
&host_at(boundary),
&["task", "list", "--source", "Work_One"],
);
refused(&output, "--source Work_One", "sources list");
}
}
#[test]
fn a_configuration_with_no_sources_at_all_says_what_to_add() {
let sandbox = Sandbox::new();
sandbox.project_document("page_size: 10\n");
refused(
&run(&sandbox, &["task", "list"]),
"no sources",
"onetaskgraph.yaml",
);
}
type Recording = fn(&Sandbox, Value) -> Value;
#[test]
fn a_reserved_dependency_key_holding_what_it_must_not_is_refused_with_a_next_action() {
for (plugin, recording) in [
("github-projects", github_projects_recording as Recording),
("linear", linear_recording as Recording),
] {
for (recorded, problem) in [
(json!(["T-2"]), "relate natively"),
(
json!([{"id": "work:T-2", "kind": "task"}]),
"relate natively",
),
(
json!({"id": "elsewhere:P-9"}),
"not a list of dependency endpoints",
),
(
json!([{"id": "bad source:P-9", "kind": "project"}]),
"source name",
),
] {
let sandbox = Sandbox::new();
let block = recording(&sandbox, recorded.clone());
sandbox.project_document(&document(
&json!({"work": {"plugin": plugin, "config": block}}),
));
let output = run(&sandbox, &["task", "deps", &qualified("work", "T-1")]);
refused(&output, problem, "sources list");
assert!(
stderr(&output).contains("onetaskgraph.depends_on"),
"{plugin} {recorded}: the message must name the key it is about:\n{}",
stderr(&output)
);
}
}
}