mockforge-contracts 0.3.241

Contract testing, drift detection, and incident management for MockForge
Documentation
use super::*;
use serde_json::json;

fn spec(schema: Value) -> Value {
    json!({"openapi":"3.0.3","info":{"title":"test","version":"1"},"paths":{
        "/items":{"get":{"responses":{"200":{"description":"ok","content":{
            "application/json":{"schema":schema}
        }}}}}
    }})
}

#[test]
fn reports_added_removed_and_changed_routes_including_head_and_trace() {
    let mut mock = spec(json!({"type":"string"}));
    let mut source = spec(json!({"type":"integer"}));
    mock["paths"]["/old"] = json!({"head":{"responses":{}}});
    source["paths"]["/new"] = json!({"trace":{"responses":{}}});
    let report = compare_mock_spec(&mock, &source).unwrap();
    assert_eq!(report.routes_with_drift, 3);
    assert_eq!(
        report
            .routes
            .iter()
            .map(|r| (r.endpoint.as_str(), r.change.as_str()))
            .collect::<Vec<_>>(),
        [
            ("GET /items", "changed"),
            ("HEAD /old", "removed"),
            ("TRACE /new", "added")
        ]
    );
    let delta = &report.routes[0].deltas[0];
    assert_eq!(delta.location, "/responses/200/content/application~1json/schema/type");
    assert_eq!(delta.mock, Some(json!("string")));
    assert_eq!(delta.source, Some(json!("integer")));
}

#[test]
fn nested_schema_fields_and_constraints_produce_explicit_deltas() {
    let mock = spec(json!({"type":"object","required":["old"],"properties":{
        "old":{"type":"string"},"a/b~c":{"type":"integer","minimum":0}
    }}));
    let source = spec(json!({"type":"object","required":["new"],"properties":{
        "new":{"type":"boolean"},"a/b~c":{"type":"string","minLength":1}
    }}));
    let report = compare_mock_spec(&mock, &source).unwrap();
    let deltas = &report.routes[0].deltas;
    assert!(deltas
        .iter()
        .any(|d| d.location.ends_with("/properties/old") && d.change == "removed"));
    assert!(deltas
        .iter()
        .any(|d| d.location.ends_with("/properties/new") && d.change == "added"));
    assert!(deltas
        .iter()
        .any(|d| d.location.ends_with("/properties/a~1b~0c/type") && d.change == "changed"));
    assert!(deltas.iter().any(|d| d.location.ends_with("/required")));
}

#[test]
fn resolves_references_and_recursive_schema_renames_without_losing_field_changes() {
    fn recursive(name: &str, kind: &str) -> Value {
        let mut s = spec(json!({"$ref":format!("#/components/schemas/{name}")}));
        s["components"] = json!({"schemas":{name:{"type":"object","properties":{
            "next":{"$ref":format!("#/components/schemas/{name}")},"id":{"type":kind}
        }}}});
        s
    }
    let mock = recursive("Old", "integer");
    let mut alias = mock.clone();
    alias["components"]["schemas"]["Alias"] = json!({"$ref":"#/components/schemas/Old"});
    alias["paths"]["/items"]["get"]["responses"]["200"]["content"]["application/json"]["schema"] =
        json!({"$ref":"#/components/schemas/Alias"});
    assert_eq!(compare_mock_spec(&alias, &mock).unwrap().routes_with_drift, 0);
    assert_eq!(
        compare_mock_spec(&mock, &recursive("New", "integer"))
            .unwrap()
            .routes_with_drift,
        0
    );
    let report = compare_mock_spec(&mock, &recursive("New", "string")).unwrap();
    assert_eq!(report.routes_with_drift, 1);
    assert!(report.routes[0]
        .deltas
        .iter()
        .any(|d| d.location.ends_with("/properties/id/type")));
}

#[test]
fn documentation_and_set_order_do_not_drift_but_named_description_fields_do() {
    let mock = spec(json!({"type":"object","description":"before","required":["id","description"],
        "properties":{"id":{"type":"integer"},"description":{"type":"string","enum":["b","a"]}}}));
    let mut source = mock.clone();
    let schema = &mut source["paths"]["/items"]["get"]["responses"]["200"]["content"]
        ["application/json"]["schema"];
    schema["description"] = json!("after");
    schema["required"] = json!(["description", "id"]);
    schema["properties"]["description"]["enum"] = json!(["a", "b"]);
    assert_eq!(compare_mock_spec(&mock, &source).unwrap().routes_with_drift, 0);
    source["paths"]["/items"]["get"]["responses"]["200"]["content"]["application/json"]["schema"]
        ["properties"]["description"]["type"] = json!("integer");
    assert_eq!(compare_mock_spec(&mock, &source).unwrap().routes_with_drift, 1);
}

#[test]
fn inherited_parameters_are_overridden_by_operation_identity() {
    let mut mock = spec(json!({"type":"string"}));
    let parameter = json!({"name":"id","in":"query","schema":{"type":"integer"}});
    mock["paths"]["/items"]["parameters"] = json!([parameter]);
    let mut source = mock.clone();
    source["paths"]["/items"].as_object_mut().unwrap().remove("parameters");
    source["paths"]["/items"]["get"]["parameters"] = json!([parameter]);
    assert_eq!(compare_mock_spec(&mock, &source).unwrap().routes_with_drift, 0);
    source["paths"]["/items"]["get"]["parameters"][0]["required"] = json!(true);
    assert_eq!(compare_mock_spec(&mock, &source).unwrap().routes_with_drift, 1);
}

#[test]
fn missing_external_and_alias_cycle_references_fail_explicitly() {
    for reference in [
        "#/components/schemas/Missing",
        "https://example.invalid/spec.json#/Thing",
        "#/components/schemas/Loop",
    ] {
        let mut value = spec(json!({"$ref":reference}));
        value["components"] = json!({"schemas":{"Loop":{"$ref":"#/components/schemas/Loop"}}});
        assert!(compare_mock_spec(&value, &value).is_err());
    }
    assert!(compare_mock_spec(&json!({"paths":{}}), &json!({"paths":{}})).is_err());
}

#[test]
fn inherited_security_compares_the_referenced_scheme_and_scope_sets() {
    let mut mock = spec(json!({"type":"string"}));
    mock["security"] = json!([{"ApiKey":[]}]);
    mock["components"] = json!({"securitySchemes":{"ApiKey":{
        "type":"apiKey","in":"header","name":"X-Old-Key"
    }}});
    let mut source = mock.clone();
    source["paths"]["/items"]["get"]["security"] = json!([{"ApiKey":[]}]);
    source.as_object_mut().unwrap().remove("security");
    assert_eq!(compare_mock_spec(&mock, &source).unwrap().routes_with_drift, 0);
    source["components"]["securitySchemes"]["ApiKey"]["name"] = json!("X-New-Key");
    let report = compare_mock_spec(&mock, &source).unwrap();
    assert!(report.routes[0]
        .deltas
        .iter()
        .any(|d| d.location == "/securitySchemes/ApiKey/name"));
    source["components"]["securitySchemes"].as_object_mut().unwrap().clear();
    assert!(compare_mock_spec(&mock, &source).is_err());
}

#[test]
fn interactive_expansion_budget_rejects_large_values_without_restricting_cli_comparison() {
    let document = spec(json!({"type":"string","default":"large".repeat(500)}));
    assert!(compare_mock_spec_with_budget(&document, &document, 1024).is_err());
    assert_eq!(compare_mock_spec(&document, &document).unwrap().routes_with_drift, 0);
}

#[test]
fn root_server_defaults_match_explicit_root_without_hiding_real_server_changes() {
    let implicit = spec(json!({"type":"string"}));
    let mut explicit = implicit.clone();
    explicit["servers"] = json!([{"url":"/"}]);
    assert_eq!(compare_mock_spec(&implicit, &explicit).unwrap().routes_with_drift, 0);
    let mut empty = implicit.clone();
    empty["servers"] = json!([]);
    assert_eq!(compare_mock_spec(&empty, &explicit).unwrap().routes_with_drift, 0);
    for location in ["path", "operation"] {
        let mut inherited = implicit.clone();
        if location == "path" {
            inherited["paths"]["/items"]["servers"] = json!([{"url":"/"}]);
        } else {
            inherited["paths"]["/items"]["get"]["servers"] = json!([{"url":"/"}]);
        }
        assert_eq!(compare_mock_spec(&implicit, &inherited).unwrap().routes_with_drift, 0);
    }
    let mut changed = explicit.clone();
    changed["servers"] = json!([{"url":"/new"}]);
    let report = compare_mock_spec(&explicit, &changed).unwrap();
    assert_eq!(report.routes_with_drift, 1);
    assert!(report.routes[0].deltas.iter().any(|d| d.location.starts_with("/servers")));
    explicit["paths"]["/items"]["get"]["servers"] = json!([{"url":"/operation"}]);
    changed["paths"]["/items"]["get"]["servers"] = json!([{"url":"/operation"}]);
    assert_eq!(compare_mock_spec(&explicit, &changed).unwrap().routes_with_drift, 0);
}

#[test]
fn server_variable_names_that_match_metadata_keys_preserve_their_contract() {
    for name in [
        "description",
        "summary",
        "example",
        "examples",
        "operationId",
        "tags",
    ] {
        let mut mock = spec(json!({"type":"string"}));
        mock["servers"] = json!([{
            "url": format!("https://{{{name}}}.example.invalid"),
            "variables": {name: {"default":"before", "enum":["before","after"]}}
        }]);
        let mut source = mock.clone();
        source["servers"][0]["variables"][name]["default"] = json!("after");
        assert_eq!(
            compare_mock_spec(&mock, &source).unwrap().routes_with_drift,
            1,
            "server variable {name} is a named contract, not documentation"
        );
        source = mock.clone();
        source["servers"][0]["variables"][name]["enum"] = json!(["before"]);
        assert_eq!(compare_mock_spec(&mock, &source).unwrap().routes_with_drift, 1);
        source = mock.clone();
        source["servers"][0]["variables"][name]["description"] = json!("documentation only");
        assert_eq!(compare_mock_spec(&mock, &source).unwrap().routes_with_drift, 0);
    }
}