use oas3::spec::{ObjectSchema, SchemaType, SchemaTypeSet, Spec};
use serde_json::json;
use crate::{
generator::{
metrics::{GenerationStats, GenerationWarning},
schema_registry::SchemaRegistry,
},
tests::common::parse_schema,
utils::{SchemaInspect, UnionFingerprints, parse_schema_ref_path},
};
const SCHEMA_REF_PREFIX: &str = "#/components/schemas/";
#[test]
fn test_parse_ref() {
let cases = [
("#/components/schemas/Corgi", Some("Corgi")),
("#/components/schemas/NestedCorgi", Some("NestedCorgi")),
("#/other/path", None),
("InvalidRef", None),
];
for (input, expected) in cases {
let result = parse_schema_ref_path(input);
assert_eq!(result.as_deref(), expected, "failed for input {input:?}");
}
}
fn spec_with_schemas(schemas_json: &serde_json::Value) -> Spec {
let spec_json = json!({
"openapi": "3.0.0",
"info": {"title": "Test", "version": "1.0.0"},
"components": {"schemas": schemas_json}
});
serde_json::from_value(spec_json).expect("failed to parse spec from JSON")
}
#[test]
fn test_ref_collector() {
let spec = spec_with_schemas(&json!({}));
let mut stats = GenerationStats::default();
let registry = SchemaRegistry::new(&spec, &mut stats);
let union_fingerprints = UnionFingerprints::new();
let schema = parse_schema(json!({
"properties": {
"related": {"$ref": "#/components/schemas/Corgi"}
}
}));
let refs = registry.collect(&schema, &union_fingerprints);
assert_eq!(refs.len(), 1, "simple ref: expected 1 ref");
assert!(refs.contains("Corgi"), "simple ref: should contain Corgi");
let schema = parse_schema(json!({
"properties": {
"waddler": {"$ref": "#/components/schemas/Corgi"},
"sploot": {"$ref": "#/components/schemas/Sploot"}
}
}));
let refs = registry.collect(&schema, &union_fingerprints);
assert_eq!(refs.len(), 2, "multiple refs: expected 2 refs");
assert!(refs.contains("Corgi"), "multiple refs: should contain Corgi");
assert!(refs.contains("Sploot"), "multiple refs: should contain Sploot");
let schema = parse_schema(json!({
"oneOf": [{"$ref": "#/components/schemas/Corgi"}],
"anyOf": [{"$ref": "#/components/schemas/Bark"}],
"allOf": [{"$ref": "#/components/schemas/Frappe"}]
}));
let refs = registry.collect(&schema, &union_fingerprints);
assert_eq!(refs.len(), 3, "combinators: expected 3 refs");
assert!(refs.contains("Corgi"), "combinators: should contain Corgi");
assert!(refs.contains("Bark"), "combinators: should contain Bark");
assert!(refs.contains("Frappe"), "combinators: should contain Frappe");
}
#[test]
fn test_schema_registry() {
let spec = spec_with_schemas(&json!({
"Corgi": {"type": "object"},
"Bark": {"type": "object"}
}));
let mut stats = GenerationStats::default();
let registry = SchemaRegistry::new(&spec, &mut stats);
assert!(registry.get("Corgi").is_some(), "should have Corgi schema");
assert!(registry.get("Bark").is_some(), "should have Bark schema");
assert!(registry.get("NonExistent").is_none(), "should not have NonExistent");
assert_eq!(registry.keys().len(), 2, "should have 2 schemas");
let spec = spec_with_schemas(&json!({
"Corgi": {"type": "object"},
"Bark": {
"type": "object",
"properties": {
"related": {"$ref": "#/components/schemas/Corgi"}
}
}
}));
let mut stats = GenerationStats::default();
let mut graph = SchemaRegistry::new(&spec, &mut stats);
let union_fingerprints = UnionFingerprints::new();
graph.build_dependencies(&union_fingerprints);
assert_eq!(graph.keys().len(), 2, "build deps: should have 2 schemas");
assert!(graph.get("Corgi").is_some(), "build deps: should have Corgi");
assert!(graph.get("Bark").is_some(), "build deps: should have Bark");
}
#[test]
fn test_schema_graph_cycle_detection() {
{
let spec = spec_with_schemas(&json!({
"A": {"type": "object"},
"B": {
"type": "object",
"properties": {
"a": {"$ref": "#/components/schemas/A"}
}
},
"C": {
"type": "object",
"properties": {
"b": {"$ref": "#/components/schemas/B"}
}
}
}));
let mut stats = GenerationStats::default();
let mut graph = SchemaRegistry::new(&spec, &mut stats);
let union_fingerprints = UnionFingerprints::new();
graph.build_dependencies(&union_fingerprints);
let cycles = graph.detect_cycles();
assert!(cycles.is_empty(), "linear deps: should have no cycles");
assert!(!graph.is_cyclic("A"), "linear deps: A should not be cyclic");
assert!(!graph.is_cyclic("B"), "linear deps: B should not be cyclic");
assert!(!graph.is_cyclic("C"), "linear deps: C should not be cyclic");
}
{
let spec = spec_with_schemas(&json!({
"A": {
"type": "object",
"properties": {
"b": {"$ref": "#/components/schemas/B"}
}
},
"B": {
"type": "object",
"properties": {
"a": {"$ref": "#/components/schemas/A"}
}
}
}));
let mut stats = GenerationStats::default();
let mut graph = SchemaRegistry::new(&spec, &mut stats);
let union_fingerprints = UnionFingerprints::new();
graph.build_dependencies(&union_fingerprints);
let cycles = graph.detect_cycles();
assert_eq!(cycles.len(), 1, "simple cycle: should detect 1 cycle");
assert!(!cycles[0].is_empty(), "simple cycle: cycle should not be empty");
assert!(graph.is_cyclic("A"), "simple cycle: A should be cyclic");
assert!(graph.is_cyclic("B"), "simple cycle: B should be cyclic");
}
{
let spec = spec_with_schemas(&json!({
"A": {
"type": "object",
"properties": {
"self_ref": {"$ref": "#/components/schemas/A"}
}
}
}));
let mut stats = GenerationStats::default();
let mut graph = SchemaRegistry::new(&spec, &mut stats);
let union_fingerprints = UnionFingerprints::new();
graph.build_dependencies(&union_fingerprints);
let cycles = graph.detect_cycles();
assert_eq!(cycles.len(), 1, "self-ref: should detect 1 cycle");
assert!(graph.is_cyclic("A"), "self-ref: A should be cyclic");
}
{
let spec = spec_with_schemas(&json!({
"User": {
"type": "object",
"properties": {
"posts": {"$ref": "#/components/schemas/Post"}
}
},
"Post": {
"type": "object",
"properties": {
"author": {"$ref": "#/components/schemas/User"}
}
}
}));
let mut stats = GenerationStats::default();
let mut graph = SchemaRegistry::new(&spec, &mut stats);
let union_fingerprints = UnionFingerprints::new();
graph.build_dependencies(&union_fingerprints);
let cycles = graph.detect_cycles();
assert!(!cycles.is_empty(), "user-post cycle: should detect cycles");
assert!(graph.is_cyclic("User"), "user-post cycle: User should be cyclic");
assert!(graph.is_cyclic("Post"), "user-post cycle: Post should be cyclic");
}
}
#[test]
fn test_schema_registry_merges_all_of_properties_and_required() {
let spec = spec_with_schemas(&json!({
"Loaf": {
"type": "object",
"required": ["tag_id"],
"properties": {
"tag_id": {"type": "integer"}
},
"additionalProperties": true
},
"Nugget": {
"type": "object",
"required": ["name"],
"properties": {
"name": {"type": "string"}
},
"allOf": [{"$ref": "#/components/schemas/Loaf"}]
}
}));
let mut stats = GenerationStats::default();
let mut graph = SchemaRegistry::new(&spec, &mut stats);
let union_fingerprints = UnionFingerprints::new();
graph.build_dependencies(&union_fingerprints);
graph.detect_cycles();
let merged = graph.merged("Nugget").expect("merged schema should exist for Nugget");
assert!(merged.schema.properties.contains_key("tag_id"));
assert!(merged.schema.properties.contains_key("name"));
assert!(merged.schema.required.contains(&"tag_id".to_string()));
assert!(merged.schema.required.contains(&"name".to_string()));
assert!(merged.schema.additional_properties.is_some());
}
#[test]
fn test_schema_registry_merges_and_tracks_discriminator_parents() {
let spec = spec_with_schemas(&json!({
"Loaf": {
"type": "object",
"properties": {
"kind": {"type": "string"}
},
"discriminator": {
"propertyName": "kind",
"mapping": {
"nugget": "#/components/schemas/Nugget"
}
}
},
"Nugget": {
"type": "object",
"properties": {
"nugget_prop": {"type": "integer"}
},
"allOf": [{"$ref": "#/components/schemas/Loaf"}]
}
}));
let mut stats = GenerationStats::default();
let mut graph = SchemaRegistry::new(&spec, &mut stats);
let union_fingerprints = UnionFingerprints::new();
graph.build_dependencies(&union_fingerprints);
graph.detect_cycles();
let merged_nugget = graph.merged("Nugget").expect("merged schema should exist for Nugget");
assert_eq!(merged_nugget.discriminator_parent.as_deref(), Some("Loaf"));
assert!(merged_nugget.schema.properties.contains_key("kind"));
assert!(merged_nugget.schema.properties.contains_key("nugget_prop"));
let parent_name = graph.parent("Nugget").expect("discriminator parent should be tracked");
assert_eq!(parent_name, "Loaf");
let effective = graph.resolved("Nugget").unwrap();
assert_eq!(effective.properties.len(), merged_nugget.schema.properties.len());
}
#[test]
fn schema_merger_conflict_resolution() {
let spec = spec_with_schemas(&json!({
"Loaf": {
"type": "object",
"properties": {
"prop": {"type": "string"}
}
},
"Nugget": {
"type": "object",
"properties": {
"prop": {"type": "integer"}
},
"allOf": [{"$ref": "#/components/schemas/Loaf"}]
}
}));
let mut stats = GenerationStats::default();
let mut graph = SchemaRegistry::new(&spec, &mut stats);
let union_fingerprints = UnionFingerprints::new();
graph.build_dependencies(&union_fingerprints);
graph.detect_cycles();
let merged = graph.merged("Nugget").expect("merged schema should exist for Nugget");
let prop = merged.schema.properties.get("prop").unwrap();
let schema = prop.as_inline().expect("Expected Object schema");
assert_eq!(
schema.schema_type,
Some(SchemaTypeSet::Single(SchemaType::Integer)),
"nugget property should override loaf"
);
}
#[test]
fn schema_merger_merge_multiple_all_of() {
let spec = spec_with_schemas(&json!({
"Corgi": {
"type": "object",
"required": ["corgi_prop"],
"properties": {
"corgi_prop": {"type": "string"}
}
},
"Fluff": {
"type": "object",
"properties": {
"fluff_prop": {"type": "integer"}
}
},
"Composite": {
"type": "object",
"allOf": [
{"$ref": "#/components/schemas/Corgi"},
{"$ref": "#/components/schemas/Fluff"}
],
"properties": {
"own_prop": {"type": "boolean"}
}
}
}));
let mut stats = GenerationStats::default();
let mut graph = SchemaRegistry::new(&spec, &mut stats);
let union_fingerprints = UnionFingerprints::new();
graph.build_dependencies(&union_fingerprints);
graph.detect_cycles();
let merged = graph
.merged("Composite")
.expect("merged schema should exist for Composite");
assert!(
merged.schema.properties.contains_key("corgi_prop"),
"should have corgi_prop"
);
assert!(
merged.schema.properties.contains_key("fluff_prop"),
"should have fluff_prop"
);
assert!(
merged.schema.properties.contains_key("own_prop"),
"should have own_prop"
);
assert!(
merged.schema.required.contains(&"corgi_prop".to_string()),
"corgi_prop should be required"
);
}
#[test]
fn implicit_discriminator_mapping_from_const_values() {
let spec = spec_with_schemas(&json!({
"Allergies": {
"type": "object",
"properties": {
"type": {"type": "string", "const": "allergies"}
},
"required": ["type"]
},
"Diet": {
"type": "object",
"properties": {
"type": {"type": "string", "const": "diet"}
},
"required": ["type"]
},
"Health": {
"type": "object",
"oneOf": [
{"$ref": "#/components/schemas/Allergies"},
{"$ref": "#/components/schemas/Diet"}
],
"discriminator": {"propertyName": "type"}
}
}));
let mut stats = GenerationStats::default();
let registry = SchemaRegistry::new(&spec, &mut stats);
let allergies_mapping = registry.mapping("Allergies");
assert!(
allergies_mapping.is_some(),
"Allergies should have a synthesized mapping"
);
let am = allergies_mapping.unwrap();
assert_eq!(am.field_name, "type", "field_name should be 'type'");
assert_eq!(am.field_value, "allergies", "field_value should be 'allergies'");
let diet_mapping = registry.mapping("Diet");
assert!(diet_mapping.is_some(), "Diet should have a synthesized mapping");
let dm = diet_mapping.unwrap();
assert_eq!(dm.field_name, "type", "field_name should be 'type'");
assert_eq!(dm.field_value, "diet", "field_value should be 'diet'");
assert!(stats.warnings.is_empty(), "should have no warnings");
}
#[test]
fn implicit_discriminator_mapping_warns_on_missing_const() {
let spec = spec_with_schemas(&json!({
"Allergies": {
"type": "object",
"properties": {
"type": {"type": "string", "const": "allergies"}
},
"required": ["type"]
},
"Diet": {
"type": "object",
"properties": {
"type": {"type": "string"}
}
},
"Health": {
"type": "object",
"oneOf": [
{"$ref": "#/components/schemas/Allergies"},
{"$ref": "#/components/schemas/Diet"}
],
"discriminator": {"propertyName": "type"}
}
}));
let mut stats = GenerationStats::default();
let registry = SchemaRegistry::new(&spec, &mut stats);
assert!(
registry.mapping("Allergies").is_none(),
"no mapping should be synthesized"
);
assert!(registry.mapping("Diet").is_none(), "no mapping should be synthesized");
assert_eq!(stats.warnings.len(), 1, "should have one warning");
assert!(
matches!(&stats.warnings[0], GenerationWarning::DiscriminatorMappingFailed { schema_name, message }
if schema_name == "Health" && message.contains("Diet") && message.contains("no string const")),
"warning should mention Diet missing const: {:?}",
stats.warnings[0]
);
}
#[test]
fn implicit_discriminator_mapping_warns_on_duplicate_const() {
let spec = spec_with_schemas(&json!({
"Allergies": {
"type": "object",
"properties": {
"type": {"type": "string", "const": "same_value"}
},
"required": ["type"]
},
"Diet": {
"type": "object",
"properties": {
"type": {"type": "string", "const": "same_value"}
},
"required": ["type"]
},
"Health": {
"type": "object",
"oneOf": [
{"$ref": "#/components/schemas/Allergies"},
{"$ref": "#/components/schemas/Diet"}
],
"discriminator": {"propertyName": "type"}
}
}));
let mut stats = GenerationStats::default();
let registry = SchemaRegistry::new(&spec, &mut stats);
assert!(
registry.mapping("Allergies").is_none(),
"no mapping should be synthesized"
);
assert!(registry.mapping("Diet").is_none(), "no mapping should be synthesized");
assert_eq!(stats.warnings.len(), 1, "should have one warning");
assert!(
matches!(&stats.warnings[0], GenerationWarning::DiscriminatorMappingFailed { schema_name, message }
if schema_name == "Health" && message.contains("duplicate")),
"warning should mention duplicate: {:?}",
stats.warnings[0]
);
}
#[test]
fn explicit_mapping_takes_precedence_over_const() {
let spec = spec_with_schemas(&json!({
"Allergies": {
"type": "object",
"properties": {
"type": {"type": "string", "const": "allergies"}
},
"required": ["type"]
},
"Diet": {
"type": "object",
"properties": {
"type": {"type": "string", "const": "diet"}
},
"required": ["type"]
},
"Health": {
"type": "object",
"oneOf": [
{"$ref": "#/components/schemas/Allergies"},
{"$ref": "#/components/schemas/Diet"}
],
"discriminator": {
"propertyName": "type",
"mapping": {
"allergy_override": "#/components/schemas/Allergies",
"diet_override": "#/components/schemas/Diet"
}
}
}
}));
let mut stats = GenerationStats::default();
let registry = SchemaRegistry::new(&spec, &mut stats);
let am = registry.mapping("Allergies").expect("Allergies should have a mapping");
assert_eq!(
am.field_value, "allergy_override",
"explicit mapping should take precedence"
);
let dm = registry.mapping("Diet").expect("Diet should have a mapping");
assert_eq!(
dm.field_value, "diet_override",
"explicit mapping should take precedence"
);
assert!(stats.warnings.is_empty(), "should have no warnings");
}
#[test]
fn test_ref_collector_additional_properties() {
let spec = spec_with_schemas(&json!({}));
let mut stats = GenerationStats::default();
let registry = SchemaRegistry::new(&spec, &mut stats);
let union_fingerprints = UnionFingerprints::new();
let schema = parse_schema(json!({
"additionalProperties": {
"$ref": "#/components/schemas/VaccineRecord"
}
}));
let refs = registry.collect(&schema, &union_fingerprints);
assert_eq!(refs.len(), 1, "direct additional_properties ref: expected 1 ref");
assert!(refs.contains("VaccineRecord"), "should contain VaccineRecord");
let schema = parse_schema(json!({
"additionalProperties": {
"type": "array",
"items": {
"$ref": "#/components/schemas/VaccineRecord"
}
}
}));
let refs = registry.collect(&schema, &union_fingerprints);
assert_eq!(refs.len(), 1, "nested additional_properties array ref: expected 1 ref");
assert!(refs.contains("VaccineRecord"), "should contain VaccineRecord via items");
let schema = parse_schema(json!({
"properties": {
"name": {"type": "string"}
},
"additionalProperties": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Metric"
}
}
}));
let refs = registry.collect(&schema, &union_fingerprints);
assert_eq!(refs.len(), 1, "properties + additional_properties: expected 1 ref");
assert!(
refs.contains("Metric"),
"should contain Metric from additional_properties"
);
let schema = parse_schema(json!({
"additionalProperties": true
}));
let refs = registry.collect(&schema, &union_fingerprints);
assert!(refs.is_empty(), "boolean additional_properties: expected 0 refs");
}
#[test]
fn test_additional_properties_reachability() {
let spec = spec_with_schemas(&json!({
"VaccineRecord": {
"type": "object",
"required": ["date_administered"],
"properties": {
"date_administered": {"type": "string", "format": "date"},
"veterinarian": {"type": "string"}
}
},
"PetVaccinations": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"$ref": "#/components/schemas/VaccineRecord"
}
}
}
}));
let mut stats = GenerationStats::default();
let mut registry = SchemaRegistry::new(&spec, &mut stats);
let union_fingerprints = UnionFingerprints::new();
registry.build_dependencies(&union_fingerprints);
let pet_vaccinations_deps = registry.collect(registry.get("PetVaccinations").unwrap(), &union_fingerprints);
assert!(
pet_vaccinations_deps.contains("VaccineRecord"),
"PetVaccinations should depend on VaccineRecord via additionalProperties"
);
}
#[test]
fn effective_mapping_synthesizes_from_cache() {
let health_json = json!({
"type": "object",
"oneOf": [
{"$ref": "#/components/schemas/Allergies"},
{"$ref": "#/components/schemas/Diet"}
],
"discriminator": {"propertyName": "type"}
});
let spec = spec_with_schemas(&json!({
"Allergies": {
"type": "object",
"properties": {
"type": {"type": "string", "const": "allergies"}
},
"required": ["type"]
},
"Diet": {
"type": "object",
"properties": {
"type": {"type": "string", "const": "diet"}
},
"required": ["type"]
},
"Health": health_json.clone()
}));
let mut stats = GenerationStats::default();
let registry = SchemaRegistry::new(&spec, &mut stats);
let health: ObjectSchema = serde_json::from_value(health_json).expect("failed to parse health schema");
let effective = registry.effective_mapping(&health);
assert!(effective.is_some(), "effective mapping should be available");
let mapping = effective.unwrap();
assert_eq!(mapping.len(), 2, "should have 2 entries");
assert_eq!(
mapping.get("allergies").map(std::string::String::as_str),
Some(format!("{SCHEMA_REF_PREFIX}Allergies").as_str()),
"allergies value should map to Allergies ref"
);
assert_eq!(
mapping.get("diet").map(std::string::String::as_str),
Some(format!("{SCHEMA_REF_PREFIX}Diet").as_str()),
"diet value should map to Diet ref"
);
}