#![expect(
clippy::expect_used,
clippy::unwrap_used,
reason = "a test that cannot build its fixture should fail loudly and name it"
)]
use typed_openapi::model::Body;
use typed_openapi::{Document, Effect, Invocation, Values, render, tree};
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];
fn document() -> Document {
Document::load(TOY, OVERLAYS).expect("the vendor's document plus the adopter's Overlay")
}
#[test]
fn the_overlay_adds_what_the_vendor_left_out() {
let doc = document();
assert!(doc.get("archiveVoucher").is_some(), "the added operation");
let Body::JsonFields(fields) = doc.get("createVoucher").unwrap().body() else {
panic!("createVoucher takes a flat JSON body");
};
assert!(
fields.iter().any(|f| f.name() == "internal_ref"),
"the added field"
);
}
#[test]
fn the_gate_is_default_closed_and_the_marker_can_only_add_writes() {
let doc = document();
let effect = |id: &str| doc.get(id).unwrap_or_else(|| panic!("{id}")).effect();
assert_eq!(effect("listVouchers"), Effect::Read);
assert_eq!(effect("getVoucher"), Effect::Read);
assert_eq!(effect("createVoucher"), Effect::Write);
assert_eq!(effect("updateVoucher"), Effect::Write);
assert_eq!(effect("enshrineVoucher"), Effect::Write);
assert_eq!(effect("archiveVoucher"), Effect::Write);
assert_eq!(effect("renderVoucher"), Effect::Write);
}
#[test]
fn every_body_is_exactly_one_flag_set() {
let doc = document();
let body = |id: &str| doc.get(id).unwrap_or_else(|| panic!("{id}")).body().clone();
assert!(matches!(body("getVoucher"), Body::None));
assert!(matches!(body("createVoucher"), Body::JsonFields(_)));
assert!(matches!(
body("createContact"),
Body::JsonWhole { required: true }
));
assert!(matches!(
body("uploadDocument"),
Body::Opaque { ref media_type, .. } if media_type == "form-data"
));
assert!(matches!(
body("uploadDocumentMultipart"),
Body::Multipart { .. }
));
}
#[test]
fn a_body_field_moves_aside_for_a_path_parameter_of_the_same_name() {
let doc = document();
let update = doc.get("updateVoucher").unwrap();
assert_eq!(update.param("id").unwrap().flag(), "id");
let Body::JsonFields(fields) = update.body() else {
panic!("updateVoucher takes a flat JSON body");
};
let id = fields.iter().find(|f| f.name() == "id").unwrap();
assert_eq!(id.flag(), "body-id");
assert!(id.renamed(), "and it says so in its help line");
}
#[test]
fn the_whole_mounted_tree_is_a_valid_clap_command() {
let doc = document();
clap::Command::new("toy")
.subcommand(clap::Command::new("raw").subcommands(tree::commands(&doc)))
.debug_assert();
}
#[test]
fn a_dry_run_prints_the_request_that_commit_would_send() {
let doc = document();
let op = doc.get("updateVoucher").unwrap();
let values = Values::new()
.param("id", 5)
.json(serde_json::json!({"total": "12.50"}));
let request = Invocation::new(op, values)
.expect("the values satisfy the operation")
.request(doc.base())
.expect("the base URL is a URL");
assert_eq!(
render(&request),
"PUT /vouchers/5 HTTP/1.1\n\
host: localhost:9999\n\
content-type: application/json\n\
\n\
{\"total\":\"12.50\"}\n"
);
}
#[test]
fn the_document_rejects_values_it_does_not_describe() {
let doc = document();
let op = doc.get("getVoucher").unwrap();
assert!(
Invocation::new(op, Values::new()).is_err(),
"`id` is required"
);
assert!(
Invocation::new(op, Values::new().param("nope", 1)).is_err(),
"there is no `nope` parameter"
);
assert!(
Invocation::new(op, Values::new().param("id", "five")).is_err(),
"`id` is an integer"
);
assert!(
Invocation::new(op, Values::new().param("id", 5).json(serde_json::json!({}))).is_err(),
"getVoucher takes no body"
);
}
#[test]
fn a_path_value_cannot_smuggle_a_segment_into_the_url() {
let doc = document();
let op = doc.get("archiveVoucher").unwrap();
let refused = Invocation::new(op, Values::new().param("id", "1/../../etc"))
.expect_err("`id` is `type: integer` in the document");
assert!(
refused.to_string().contains("is not an integer"),
"{refused}"
);
let doc = Document::load(
&TOY.replace(
" schema:\n type: integer\n format: int64",
" schema:\n type: string",
),
OVERLAYS,
)
.expect("a document whose ids are strings");
let op = doc.get("getVoucher").unwrap();
let request = Invocation::new(op, Values::new().param("id", "1/../../etc"))
.unwrap()
.request(doc.base())
.unwrap();
assert_eq!(request.uri().path(), "/vouchers/1%2F..%2F..%2Fetc");
}
#[test]
fn a_rule_a_named_schema_states_reaches_the_property_pointing_at_it() {
let doc = document();
let Body::JsonFields(fields) = doc.get("updateVoucher").unwrap().body() else {
panic!("updateVoucher takes a flat JSON body");
};
let total = fields.iter().find(|f| f.name() == "total").unwrap();
assert_eq!(
total.scalar().note().as_deref(),
Some(r"matches ^-?[0-9]+(\.[0-9]{1,2})?$"),
"the rule the `Money` schema states did not reach `total`"
);
assert_eq!(
total
.scalar()
.parse("1,50")
.expect_err("a comma is not a decimal point")
.to_string(),
r"`1,50` does not match ^-?[0-9]+(\.[0-9]{1,2})?$"
);
assert!(total.scalar().parse("12.50").is_ok());
}
#[test]
fn every_rule_a_parameter_states_is_checked_because_nothing_else_can_check_it() {
const PARAMETERS: &str = " /vouchers:\n\
\x20 get:\n\
\x20 operationId: listVouchers\n\
\x20 parameters:\n\
\x20 - { name: since, in: query, schema: { type: string, pattern: '^[0-9]{4}-[0-9]{2}$' } }\n\
\x20 - { name: code, in: query, schema: { type: string, minLength: 3, maxLength: 3 } }\n\
\x20 - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, multipleOf: 5 } }\n\
\x20 responses: { \"200\": { description: OK } }\n";
let doc = Document::load(&synthetic(PARAMETERS), &[]).expect("a document");
let op = doc.get("listVouchers").unwrap();
let refused = |name: &str, value: &str| {
Invocation::new(op, Values::new().param(name, value))
.expect_err("the document refuses it")
.to_string()
};
assert_eq!(
refused("since", "2026-9"),
"listVouchers: `since`: `2026-9` does not match ^[0-9]{4}-[0-9]{2}$"
);
assert_eq!(
refused("code", "EU"),
"listVouchers: `code`: `EU` is shorter than 3 characters"
);
assert_eq!(
refused("code", "EURO"),
"listVouchers: `code`: `EURO` is longer than 3 characters"
);
assert_eq!(
refused("limit", "0"),
"listVouchers: `limit`: `0` is not at least 1"
);
assert_eq!(
refused("limit", "105"),
"listVouchers: `limit`: `105` is not at most 100"
);
assert_eq!(
refused("limit", "7"),
"listVouchers: `limit`: `7` is not a multiple of 5"
);
assert!(
Invocation::new(
op,
Values::new()
.param("since", "2026-09")
.param("code", "EUR")
.param("limit", "25"),
)
.is_ok()
);
}
#[test]
fn a_pattern_no_engine_can_read_is_refused_while_the_document_is_reduced() {
let error = Document::load(
&synthetic(
" /vouchers:\n\
\x20 get:\n\
\x20 operationId: listVouchers\n\
\x20 parameters:\n\
\x20 - { name: since, in: query, schema: { type: string, pattern: '[unterminated' } }\n\
\x20 responses: { \"200\": { description: OK } }\n",
),
&[],
)
.expect_err("`[unterminated` is not a regular expression");
assert_eq!(
error.to_string(),
"listVouchers: `since`: `[unterminated` is not a regular expression: Unbalanced bracket"
);
}
#[test]
fn a_reduction_survives_the_round_trip_the_bless_step_makes() {
let doc = document();
let blob = doc.to_blob().expect("the reduction encodes");
assert_eq!(Document::from_blob(&blob).expect("and decodes"), doc);
}
#[test]
fn a_blob_that_is_not_a_reduction_is_refused_by_name() {
let error = Document::from_blob(b"not a reduction").expect_err("not a reduction");
assert!(error.to_string().contains("reduced model"), "{error}");
}
fn synthetic(paths: &str) -> String {
format!(
"openapi: 3.0.3\n\
info: {{ title: t, version: \"1\" }}\n\
servers: [{{ url: 'http://localhost:9999' }}]\n\
paths:\n{paths}"
)
}
fn placements(document: &str) -> Vec<String> {
Document::load(document, &[])
.expect("a document")
.iter()
.map(|op| format!("{} {}", op.group(), op.command()))
.collect()
}
#[test]
fn a_prefix_every_path_shares_is_not_the_group() {
let placed = placements(&synthetic(
" /v1/vouchers:\n\
\x20 get: { operationId: listVouchers, responses: { \"200\": { description: OK } } }\n\
\x20 /v1/contacts:\n\
\x20 post: { operationId: createContact, responses: { \"201\": { description: OK } } }\n",
));
assert_eq!(placed, ["vouchers list", "contacts create"]);
}
#[test]
fn two_operations_under_one_name_are_refused_by_both_ids() {
const COLLIDING: &str = " /vouchers/{id}/render:\n\
\x20 get: { operationId: renderVoucher, responses: { \"200\": { description: OK } } }\n\
\x20 /vouchers/{id}/pdf/render:\n\
\x20 get: { operationId: renderVoucherPdf, responses: { \"200\": { description: OK } } }\n";
let error = Document::load(&synthetic(COLLIDING), &[]).expect_err("both are `vouchers render`");
assert_eq!(
error.to_string(),
"`renderVoucher` and `renderVoucherPdf` are both `vouchers render` on the \
command line; give one of them an `x-cli-command`"
);
let resolved = COLLIDING.replace(
"operationId: renderVoucherPdf",
"operationId: renderVoucherPdf, x-cli-command: render-pdf",
);
assert_eq!(
placements(&synthetic(&resolved)),
["vouchers render", "vouchers render-pdf"]
);
}
#[test]
fn the_document_may_name_its_own_group_and_command() {
let placed = placements(&synthetic(
" /vouchers/{id}/render:\n\
\x20 get:\n\
\x20 operationId: renderVoucher\n\
\x20 x-cli-group: reports\n\
\x20 x-cli-command: pdf\n\
\x20 responses: { \"200\": { description: OK } }\n\
\x20 /contacts:\n\
\x20 post: { operationId: createContact, responses: { \"201\": { description: OK } } }\n",
));
assert_eq!(placed, ["reports pdf", "contacts create"]);
}
#[test]
fn a_marker_that_is_not_a_name_is_refused() {
let error = Document::load(
&synthetic(
" /vouchers:\n\
\x20 get:\n\
\x20 operationId: listVouchers\n\
\x20 x-cli-command: [a, b]\n\
\x20 responses: { \"200\": { description: OK } }\n",
),
&[],
)
.expect_err("a list is not a name");
assert_eq!(
error.to_string(),
"listVouchers: `x-cli-command` is not a string"
);
}
#[test]
fn an_override_that_is_not_spellable_names_itself() {
let error = Document::load(
&synthetic(
" /vouchers:\n\
\x20 get:\n\
\x20 operationId: listVouchers\n\
\x20 x-cli-group: \"???\"\n\
\x20 responses: { \"200\": { description: OK } }\n",
),
&[],
)
.expect_err("`???` is not a name");
assert_eq!(
error.to_string(),
"the x-cli-group `???` does not kebab-case into [a-z0-9-]"
);
}