#![expect(
clippy::expect_used,
reason = "a test that cannot build its fixture should fail loudly and name it"
)]
use clap::{ArgMatches, Command};
use typed_openapi::tree::{self, DispatchError, Outcome};
use typed_openapi::{Document, HttpRequest, Recorder, render};
const TOY: &str = include_str!("fixtures/toy.yaml");
const CORRECTIONS: &str = include_str!("fixtures/corrections.yaml");
const CLI: &str = include_str!("fixtures/cli.yaml");
const OVERLAYS: &[&str] = &[CORRECTIONS, CLI];
const CREATE: &[&str] = &[
"toy",
"vouchers",
"create",
"--total",
"12.50",
"--currency",
"EUR",
"--status",
"open",
];
fn document() -> Document {
Document::load(TOY, OVERLAYS).expect("the vendor's document plus the adopter's Overlay")
}
fn root(doc: &Document) -> Command {
Command::new("toy")
.subcommand_required(true)
.subcommands(tree::commands(doc))
}
fn parse(doc: &Document, args: &[&str]) -> ArgMatches {
root(doc).get_matches_from(args)
}
fn only(client: &Recorder) -> HttpRequest {
let mut sent = client.take();
assert_eq!(sent.len(), 1, "exactly one request went out");
sent.pop().expect("the length is one")
}
#[test]
fn operations_mounted_as_the_cli_itself_are_a_valid_clap_command() {
root(&document()).debug_assert();
}
#[test]
fn a_read_is_sent_on_sight() {
let doc = document();
let client = Recorder::new();
let matches = parse(&doc, &["toy", "vouchers", "get", "--id", "5"]);
let outcome = tree::dispatch(&doc, doc.base(), &client, &matches).expect("a read dispatches");
assert!(matches!(outcome, Outcome::Sent(_)), "a read is sent");
let sent = only(&client);
assert_eq!(sent.uri().path(), "/vouchers/5");
assert_eq!(sent.method(), "GET");
}
#[test]
fn a_value_the_documents_rule_refuses_never_becomes_a_request() {
let doc = document();
let refused = root(&doc)
.try_get_matches_from([
"toy",
"vouchers",
"create",
"--total",
"1,50",
"--currency",
"EUR",
"--status",
"open",
])
.expect_err("a comma is not a decimal point");
let refused = refused.to_string();
assert!(
refused.contains(r"invalid value '1,50' for '--total <STRING>'"),
"{refused}"
);
assert!(
refused.contains(r"`1,50` does not match ^-?[0-9]+(\.[0-9]{1,2})?$"),
"{refused}"
);
let client = Recorder::new();
let outcome = tree::dispatch(&doc, doc.base(), &client, &parse(&doc, CREATE))
.expect("a valid amount dispatches");
let Outcome::DryRun(request) = outcome else {
panic!("a create is a write, so it is a dry run without --commit");
};
assert!(
render(&request).contains(r#""total":"12.50""#),
"{}",
render(&request)
);
}
#[test]
fn a_write_nobody_confirmed_sends_nothing() {
let doc = document();
let client = Recorder::new();
let matches = parse(&doc, CREATE);
let outcome = tree::dispatch(&doc, doc.base(), &client, &matches).expect("a write dispatches");
let Outcome::DryRun(request) = outcome else {
panic!("a write without --commit is a dry run");
};
assert!(
client.take().is_empty(),
"a dry run reaches the client with nothing"
);
assert_eq!(
render(&request),
"POST /vouchers HTTP/1.1\n\
host: localhost:9999\n\
content-type: application/json\n\
\n\
{\"total\":\"12.50\",\"currency\":\"EUR\",\"status\":\"open\"}\n"
);
}
#[test]
fn commit_sends_exactly_what_the_dry_run_printed() {
let doc = document();
let dry = {
let client = Recorder::new();
let matches = parse(&doc, CREATE);
match tree::dispatch(&doc, doc.base(), &client, &matches).expect("dispatches") {
Outcome::DryRun(request) => render(&request),
Outcome::Sent(_) => panic!("no --commit was given"),
}
};
let client = Recorder::new();
let confirmed: Vec<&str> = CREATE.iter().copied().chain(["--commit"]).collect();
let matches = parse(&doc, &confirmed);
let outcome = tree::dispatch(&doc, doc.base(), &client, &matches).expect("dispatches");
assert!(matches!(outcome, Outcome::Sent(_)), "--commit sends");
assert_eq!(render(&only(&client)), dry);
}
#[test]
fn a_gated_get_is_held_back_like_any_write() {
let doc = document();
let client = Recorder::new();
let matches = parse(&doc, &["toy", "vouchers", "render", "--id", "5"]);
let outcome = tree::dispatch(&doc, doc.base(), &client, &matches).expect("dispatches");
assert!(matches!(outcome, Outcome::DryRun(_)));
assert!(client.take().is_empty());
}
#[test]
fn the_shortcut_and_the_seam_reach_the_same_request() {
let doc = document();
let long_way = {
let client = Recorder::new();
let matches = parse(&doc, &["toy", "vouchers", "get", "--id", "5"]);
let selected = tree::select(&doc, &matches).expect("the subcommand names an operation");
assert_eq!(selected.operation().id(), "getVoucher");
assert!(!selected.confirmed(), "a read carries no --commit flag");
selected.send(&client, doc.base()).expect("sends");
only(&client)
};
let short_way = {
let client = Recorder::new();
let matches = parse(&doc, &["toy", "vouchers", "get", "--id", "5"]);
tree::dispatch(&doc, doc.base(), &client, &matches).expect("sends");
only(&client)
};
assert_eq!(render(&long_way), render(&short_way));
}
#[test]
fn the_tree_mounts_under_any_name_and_never_looks_above_itself() {
let doc = document();
let client = Recorder::new();
let matches = Command::new("app")
.subcommand(Command::new("passthrough").subcommands(tree::commands(&doc)))
.get_matches_from(["app", "passthrough", "vouchers", "get", "--id", "5"]);
let mounted = matches
.subcommand_matches("passthrough")
.expect("the subcommand parsed");
tree::dispatch(&doc, doc.base(), &client, mounted).expect("dispatches under any name");
assert_eq!(only(&client).uri().path(), "/vouchers/5");
}
#[test]
fn a_name_the_document_does_not_describe_is_refused_by_both_halves() {
let doc = document();
let matches = Command::new("toy")
.subcommand(Command::new("vouchers").subcommand(Command::new("not-an-operation")))
.get_matches_from(["toy", "vouchers", "not-an-operation"]);
let error = tree::select(&doc, &matches).expect_err("no such operation");
assert!(matches!(&error, DispatchError::Unknown { group, command }
if group == "vouchers" && command == "not-an-operation"));
assert_eq!(
error.to_string(),
"no operation named `vouchers not-an-operation` in the document"
);
}
#[test]
fn operations_are_mounted_under_the_resource_their_path_names() {
let doc = document();
let tree: Vec<(String, Vec<String>)> = tree::commands(&doc)
.iter()
.map(|group| {
(
group.get_name().to_owned(),
group
.get_subcommands()
.map(|op| op.get_name().to_owned())
.collect(),
)
})
.collect();
assert_eq!(
tree,
[
(
"vouchers".to_owned(),
vec![
"list".to_owned(),
"create".to_owned(),
"get".to_owned(),
"update".to_owned(),
"enshrine".to_owned(),
"render".to_owned(),
"archive".to_owned(),
]
),
("contacts".to_owned(), vec!["create".to_owned()]),
("documents".to_owned(), vec!["create".to_owned()]),
("documents-multipart".to_owned(), vec!["create".to_owned()]),
]
);
}
#[test]
fn no_subcommand_at_all_is_its_own_error() {
let doc = document();
let matches = Command::new("toy").get_matches_from(["toy"]);
let error = tree::select(&doc, &matches).expect_err("nothing was named");
assert!(matches!(error, DispatchError::NoCommand));
assert_eq!(error.to_string(), "no command given");
}
#[test]
fn a_group_with_no_operation_under_it_is_the_same_error() {
let doc = document();
let matches = Command::new("toy")
.subcommand(Command::new("vouchers"))
.get_matches_from(["toy", "vouchers"]);
assert!(matches!(
tree::select(&doc, &matches).expect_err("no operation was named"),
DispatchError::NoCommand
));
}
fn flag<'c>(command: &'c Command, long: &str) -> &'c clap::Arg {
command
.get_arguments()
.find(|arg| arg.get_long() == Some(long))
.unwrap_or_else(|| panic!("no --{long} on `{}`", command.get_name()))
}
fn command_for(doc: &Document, id: &str) -> Command {
tree::command(doc.get(id).unwrap_or_else(|| panic!("{id} is documented")))
}
#[test]
fn an_enum_in_the_document_reaches_the_command_line_as_choices() {
let doc = document();
let choices: Vec<String> = flag(&command_for(&doc, "createVoucher"), "status")
.get_possible_values()
.iter()
.map(|value| value.get_name().to_owned())
.collect();
assert_eq!(choices, ["draft", "open", "paid"]);
}
#[test]
fn only_an_operation_that_writes_carries_the_commit_flag() {
let doc = document();
let has_commit = |id: &str| {
command_for(&doc, id)
.get_arguments()
.any(|arg| arg.get_long() == Some("commit"))
};
assert!(!has_commit("listVouchers"), "a read runs on sight");
assert!(!has_commit("getVoucher"), "a read runs on sight");
assert!(has_commit("createVoucher"), "a POST is gated");
assert!(
has_commit("renderVoucher"),
"a documented writing GET is gated"
);
}