use serde_json::{Value, json};
use super::diff::diff;
use crate::compat::{Change, Severity, is_compatible};
fn doc(request: Option<Value>, response: Value) -> Value {
let mut op = json!({
"operationId": "createItem",
"responses": {
"200": { "description": "OK",
"content": { "application/json": { "schema": response } } }
}
});
if let Some(r) = request {
op["requestBody"] = json!({
"required": true,
"content": { "application/json": { "schema": r } },
});
}
json!({ "openapi": "3.1.0", "paths": { "/items": { "post": op } } })
}
fn object(props: Value, required: &[&str]) -> Value {
json!({ "type": "object", "properties": props, "required": required })
}
fn at(changes: &[Change], needle: &str) -> Vec<String> {
changes
.iter()
.filter(|c| c.at.contains(needle))
.map(|c| format!("{:?} {}", c.severity, c.detail))
.collect()
}
#[test]
fn an_identical_document_has_no_changes() {
let d = doc(
Some(object(json!({ "a": { "type": "string" } }), &["a"])),
json!({}),
);
assert!(diff(&d, &d).is_empty());
assert!(is_compatible(&diff(&d, &d)));
}
#[test]
fn removing_an_operation_breaks_callers() {
let old = doc(None, json!({}));
let new = json!({ "openapi": "3.1.0", "paths": {} });
let changes = diff(&old, &new);
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].severity, Severity::Breaking);
assert_eq!(changes[0].detail, "operation removed");
assert!(!is_compatible(&changes));
}
#[test]
fn adding_an_operation_is_additive() {
let old = json!({ "openapi": "3.1.0", "paths": {} });
let new = doc(None, json!({}));
let changes = diff(&old, &new);
assert_eq!(changes[0].severity, Severity::Additive);
assert!(is_compatible(&changes));
}
#[test]
fn a_newly_required_request_field_breaks_callers() {
let old = doc(
Some(object(json!({ "a": { "type": "string" } }), &["a"])),
json!({}),
);
let new = doc(
Some(object(
json!({ "a": { "type": "string" }, "b": { "type": "string" } }),
&["a", "b"],
)),
json!({}),
);
let changes = diff(&old, &new);
assert_eq!(
at(&changes, "requestBody.b"),
vec!["Breaking property added"]
);
assert!(!is_compatible(&changes));
}
#[test]
fn a_newly_optional_request_field_is_additive() {
let old = doc(
Some(object(json!({ "a": { "type": "string" } }), &["a"])),
json!({}),
);
let new = doc(
Some(object(
json!({ "a": { "type": "string" }, "b": { "type": "string" } }),
&["a"],
)),
json!({}),
);
assert!(is_compatible(&diff(&old, &new)));
}
#[test]
fn a_new_response_field_is_additive_even_when_required() {
let old = doc(None, object(json!({ "a": { "type": "string" } }), &["a"]));
let new = doc(
None,
object(
json!({ "a": { "type": "string" }, "b": { "type": "string" } }),
&["a", "b"],
),
);
let changes = diff(&old, &new);
assert!(is_compatible(&changes), "got {changes:?}");
}
#[test]
fn a_response_field_that_stops_being_guaranteed_breaks_readers() {
let old = doc(None, object(json!({ "a": { "type": "string" } }), &["a"]));
let new = doc(None, object(json!({ "a": { "type": "string" } }), &[]));
let changes = diff(&old, &new);
assert_eq!(at(&changes, ".a"), vec!["Breaking no longer guaranteed"]);
}
#[test]
fn a_request_field_that_stops_being_required_is_additive() {
let old = doc(
Some(object(json!({ "a": { "type": "string" } }), &["a"])),
json!({}),
);
let new = doc(
Some(object(json!({ "a": { "type": "string" } }), &[])),
json!({}),
);
let changes = diff(&old, &new);
assert!(is_compatible(&changes), "got {changes:?}");
}
#[test]
fn an_optional_request_field_becoming_required_breaks_callers() {
let old = doc(
Some(object(json!({ "a": { "type": "string" } }), &[])),
json!({}),
);
let new = doc(
Some(object(json!({ "a": { "type": "string" } }), &["a"])),
json!({}),
);
assert_eq!(
at(&diff(&old, &new), ".a"),
vec!["Breaking became required"]
);
}
#[test]
fn a_changed_type_is_breaking_in_either_direction() {
let old = doc(None, object(json!({ "a": { "type": "string" } }), &[]));
let new = doc(None, object(json!({ "a": { "type": "integer" } }), &[]));
let changes = diff(&old, &new);
assert_eq!(at(&changes, ".a"), vec!["Breaking type string -> integer"]);
}
#[test]
fn a_removed_property_is_breaking() {
let old = doc(None, object(json!({ "a": { "type": "string" } }), &[]));
let new = doc(None, object(json!({}), &[]));
assert_eq!(
at(&diff(&old, &new), ".a"),
vec!["Breaking property removed"]
);
}
#[test]
fn a_removed_enum_value_is_breaking_and_an_added_one_is_not() {
let old = doc(
None,
object(
json!({ "s": { "type": "string", "enum": ["a", "b"] } }),
&[],
),
);
let new = doc(
None,
object(
json!({ "s": { "type": "string", "enum": ["a", "c"] } }),
&[],
),
);
let changes = at(&diff(&old, &new), ".s");
assert!(changes.contains(&"Breaking value b no longer accepted".to_string()));
assert!(changes.contains(&"Additive value c added".to_string()));
}
#[test]
fn array_element_types_are_compared() {
let old = doc(
None,
json!({ "type": "array", "items": { "type": "string" } }),
);
let new = doc(
None,
json!({ "type": "array", "items": { "type": "integer" } }),
);
let changes = diff(&old, &new);
assert!(!is_compatible(&changes), "got {changes:?}");
assert!(changes[0].at.contains("[]"), "got {:?}", changes[0]);
}
fn doc_with_params(params: Value) -> Value {
json!({ "openapi": "3.1.0", "paths": { "/items": { "get": {
"operationId": "listItems",
"parameters": params,
"responses": { "200": { "description": "OK" } },
}}}})
}
#[test]
fn a_new_required_parameter_breaks_callers() {
let old = doc_with_params(json!([]));
let new = doc_with_params(json!([
{ "name": "q", "in": "query", "required": true, "schema": { "type": "string" } }
]));
let changes = diff(&old, &new);
assert_eq!(at(&changes, "query:q"), vec!["Breaking parameter added"]);
}
#[test]
fn a_new_optional_parameter_is_additive() {
let old = doc_with_params(json!([]));
let new = doc_with_params(json!([
{ "name": "q", "in": "query", "required": false, "schema": { "type": "string" } }
]));
assert!(is_compatible(&diff(&old, &new)));
}
#[test]
fn a_removed_required_parameter_is_breaking() {
let old = doc_with_params(json!([
{ "name": "q", "in": "query", "required": true, "schema": { "type": "string" } }
]));
let new = doc_with_params(json!([]));
assert_eq!(
at(&diff(&old, &new), "query:q"),
vec!["Breaking parameter removed"]
);
}
#[test]
fn the_same_name_in_two_locations_is_not_confused() {
let old = doc_with_params(json!([
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }
]));
let new = doc_with_params(json!([
{ "name": "id", "in": "query", "required": true, "schema": { "type": "string" } }
]));
let changes = diff(&old, &new);
assert_eq!(at(&changes, "path:id"), vec!["Breaking parameter removed"]);
assert_eq!(at(&changes, "query:id"), vec!["Breaking parameter added"]);
}
#[test]
fn a_removed_response_status_is_breaking() {
let old = json!({ "openapi": "3.1.0", "paths": { "/i": { "get": { "responses": {
"200": { "description": "OK" }, "404": { "description": "Not Found" } }}}}});
let new = json!({ "openapi": "3.1.0", "paths": { "/i": { "get": { "responses": {
"200": { "description": "OK" } }}}}});
let changes = diff(&old, &new);
assert_eq!(
at(&changes, "404"),
vec!["Breaking response status removed"]
);
}
#[test]
fn refs_are_followed_into_each_documents_own_components() {
let build = |ty: &str| {
json!({
"openapi": "3.1.0",
"paths": { "/i": { "get": { "responses": { "200": {
"description": "OK",
"content": { "application/json": {
"schema": { "$ref": "#/components/schemas/Item" } } } } } } } },
"components": { "schemas": { "Item": {
"type": "object", "properties": { "id": { "type": ty } } } } },
})
};
let changes = diff(&build("string"), &build("integer"));
assert_eq!(at(&changes, ".id"), vec!["Breaking type string -> integer"]);
}
#[test]
fn self_referential_schemas_terminate() {
let build = |ty: &str| {
json!({
"openapi": "3.1.0",
"paths": { "/i": { "get": { "responses": { "200": {
"description": "OK",
"content": { "application/json": {
"schema": { "$ref": "#/components/schemas/Node" } } } } } } } },
"components": { "schemas": { "Node": {
"type": "object",
"properties": {
"value": { "type": ty },
"next": { "$ref": "#/components/schemas/Node" },
} } } },
})
};
let changes = diff(&build("string"), &build("integer"));
assert!(!is_compatible(&changes), "the real change is still found");
}
#[test]
fn breaking_changes_are_listed_before_additive_ones() {
let old = doc(None, object(json!({ "a": { "type": "string" } }), &["a"]));
let new = doc(
None,
object(
json!({ "a": { "type": "integer" }, "z": { "type": "string" } }),
&["a"],
),
);
let changes = diff(&old, &new);
assert_eq!(changes[0].severity, Severity::Breaking);
assert_eq!(
changes.last().map(|c| c.severity),
Some(Severity::Additive),
"got {changes:?}"
);
}