use std::path::Path;
use serde_json::{json, Value};
use ucp_schema::{
compose_from_payload, is_container_schema, select_operation_schema, validate, Direction,
ResolveOptions, SchemaBaseConfig, ValidateError,
};
fn write_fixtures(dir: &Path) {
let shopping = dir.join("schemas/shopping");
std::fs::create_dir_all(&shopping).unwrap();
std::fs::write(
shopping.join("catalog_search.json"),
r##"{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://ucp.dev/schemas/shopping/catalog_search.json",
"name": "dev.ucp.shopping.catalog.search",
"type": "object",
"$defs": {
"search_request": {
"type": "object",
"required": ["query"],
"properties": { "query": { "type": "string" } }
},
"search_response": {
"type": "object",
"required": ["products"],
"properties": {
"products": { "type": "array", "items": { "$ref": "#/$defs/product" } }
}
},
"product": {
"type": "object",
"required": ["id", "title"],
"properties": {
"id": { "type": "string" },
"title": { "type": "string" }
}
}
}
}"##,
)
.unwrap();
std::fs::write(
shopping.join("fulfillment.json"),
r##"{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://ucp.dev/schemas/shopping/fulfillment.json",
"name": "dev.ucp.shopping.fulfillment",
"$defs": {
"dev.ucp.shopping.catalog.search": {
"$defs": {
"search_request": { "$ref": "#/$defs/ful_search_request" },
"search_response": { "$ref": "#/$defs/ful_search_response" }
}
},
"ful_search_request": {
"allOf": [
{ "$ref": "catalog_search.json#/$defs/search_request" },
{ "type": "object", "properties": { "radius_km": { "type": "number" } } }
]
},
"ful_search_response": {
"allOf": [
{ "$ref": "catalog_search.json#/$defs/search_response" },
{
"type": "object",
"properties": {
"products": { "type": "array", "items": { "$ref": "#/$defs/ful_product" } }
}
}
]
},
"ful_product": {
"allOf": [
{ "$ref": "catalog_search.json#/$defs/product" },
{
"type": "object",
"properties": {
"fulfillment_methods": { "type": "array", "items": { "type": "string" } }
}
}
]
}
}
}"##,
)
.unwrap();
std::fs::write(
shopping.join("checkout.json"),
r##"{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://ucp.dev/schemas/shopping/checkout.json",
"name": "dev.ucp.shopping.checkout",
"type": "object",
"required": ["id"],
"properties": { "id": { "type": "string" } }
}"##,
)
.unwrap();
std::fs::write(
shopping.join("cart.json"),
r##"{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://ucp.dev/schemas/shopping/cart.json",
"name": "dev.ucp.shopping.cart",
"type": "object",
"required": ["id"],
"properties": { "id": { "type": "string" } },
"$defs": {
"checkout": {
"type": "object",
"required": ["token"],
"properties": { "token": { "type": "string" } }
}
}
}"##,
)
.unwrap();
std::fs::write(
shopping.join("loyalty.json"),
r##"{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://ucp.dev/schemas/shopping/loyalty.json",
"name": "dev.ucp.shopping.loyalty",
"$defs": {
"dev.ucp.shopping.catalog.search": {
"allOf": [
{ "$ref": "catalog_search.json#/$defs/search_response" },
{ "type": "object", "properties": { "rewards": { "type": "array" } } }
]
}
}
}"##,
)
.unwrap();
}
fn config(dir: &Path) -> SchemaBaseConfig<'static> {
let base: &'static Path = Box::leak(dir.join("schemas").into_boxed_path());
SchemaBaseConfig {
local_base: Some(base),
remote_base: Some("https://ucp.dev/schemas"),
}
}
fn search_payload(products: Value) -> Value {
json!({
"ucp": { "capabilities": {
"dev.ucp.shopping.catalog.search": [
{ "version": "2026-04-08", "schema": "https://ucp.dev/schemas/shopping/catalog_search.json" }
]
} },
"products": products
})
}
fn search_payload_with_fulfillment(products: Value) -> Value {
json!({
"ucp": { "capabilities": {
"dev.ucp.shopping.catalog.search": [
{ "version": "2026-04-08", "schema": "https://ucp.dev/schemas/shopping/catalog_search.json" }
],
"dev.ucp.shopping.fulfillment": [
{ "version": "2026-04-08", "schema": "https://ucp.dev/schemas/shopping/fulfillment.json", "extends": "dev.ucp.shopping.catalog.search" }
]
} },
"products": products
})
}
#[test]
fn base_container_bad_response_is_invalid() {
let dir = tempfile::tempdir().unwrap();
write_fixtures(dir.path());
let cfg = config(dir.path());
let payload = search_payload(json!([{ "id": "p1", "BOGUS": true }]));
let schema = compose_from_payload(&payload, &cfg).unwrap();
let opts = ResolveOptions::new(Direction::Response, "search");
let result = validate(&schema, &payload, &opts);
assert!(
matches!(result, Err(ValidateError::Invalid { .. })),
"bad catalog search response must be INVALID, got {:?}",
result
);
}
#[test]
fn base_container_good_response_is_valid() {
let dir = tempfile::tempdir().unwrap();
write_fixtures(dir.path());
let cfg = config(dir.path());
let payload = search_payload(json!([{ "id": "p1", "title": "Widget" }]));
let schema = compose_from_payload(&payload, &cfg).unwrap();
let opts = ResolveOptions::new(Direction::Response, "search");
assert!(validate(&schema, &payload, &opts).is_ok());
}
#[test]
fn base_container_request_selects_request_shape() {
let dir = tempfile::tempdir().unwrap();
write_fixtures(dir.path());
let cfg = config(dir.path());
let payload = search_payload(json!([{ "id": "p1", "title": "Widget" }]));
let schema = compose_from_payload(&payload, &cfg).unwrap();
let opts = ResolveOptions::new(Direction::Request, "search");
assert!(matches!(
validate(&schema, &payload, &opts),
Err(ValidateError::Invalid { .. })
));
}
#[test]
fn missing_operation_shape_fails_loud() {
let dir = tempfile::tempdir().unwrap();
write_fixtures(dir.path());
let cfg = config(dir.path());
let payload = search_payload(json!([{ "id": "p1", "title": "Widget" }]));
let schema = compose_from_payload(&payload, &cfg).unwrap();
let opts = ResolveOptions::new(Direction::Response, "read");
let result = validate(&schema, &payload, &opts);
match result {
Err(ValidateError::Resolve(e)) => {
let msg = e.to_string();
assert!(
msg.contains("read_response") && msg.contains("search_response"),
"expected fail-loud naming missing + available shapes, got: {msg}"
);
}
other => panic!("expected loud Resolve error, got {:?}", other),
}
}
#[test]
fn extension_good_response_is_valid() {
let dir = tempfile::tempdir().unwrap();
write_fixtures(dir.path());
let cfg = config(dir.path());
let payload = search_payload_with_fulfillment(json!([{
"id": "p1", "title": "Widget", "fulfillment_methods": ["shipping"]
}]));
let schema = compose_from_payload(&payload, &cfg).unwrap();
let opts = ResolveOptions::new(Direction::Response, "search");
assert!(
validate(&schema, &payload, &opts).is_ok(),
"valid fulfillment-extended response should pass"
);
}
#[test]
fn extension_base_constraint_still_applies() {
let dir = tempfile::tempdir().unwrap();
write_fixtures(dir.path());
let cfg = config(dir.path());
let payload = search_payload_with_fulfillment(json!([{
"id": "p1", "fulfillment_methods": ["shipping"]
}]));
let schema = compose_from_payload(&payload, &cfg).unwrap();
let opts = ResolveOptions::new(Direction::Response, "search");
assert!(
matches!(
validate(&schema, &payload, &opts),
Err(ValidateError::Invalid { .. })
),
"base 'title' requirement must still apply under the extension"
);
}
#[test]
fn extension_constraint_applies() {
let dir = tempfile::tempdir().unwrap();
write_fixtures(dir.path());
let cfg = config(dir.path());
let payload = search_payload_with_fulfillment(json!([{
"id": "p1", "title": "Widget", "fulfillment_methods": "NOT_AN_ARRAY"
}]));
let schema = compose_from_payload(&payload, &cfg).unwrap();
let opts = ResolveOptions::new(Direction::Response, "search");
assert!(
matches!(
validate(&schema, &payload, &opts),
Err(ValidateError::Invalid { .. })
),
"extension 'fulfillment_methods' type must be enforced (proves per-op merge)"
);
}
#[test]
fn single_object_capability_unchanged() {
let dir = tempfile::tempdir().unwrap();
write_fixtures(dir.path());
let cfg = config(dir.path());
let good = json!({
"ucp": { "capabilities": { "dev.ucp.shopping.checkout": [
{ "version": "2026-04-08", "schema": "https://ucp.dev/schemas/shopping/checkout.json" } ] } },
"id": "co_1"
});
let schema = compose_from_payload(&good, &cfg).unwrap();
assert!(
!is_container_schema(&schema),
"checkout root is a single object, not a container"
);
let opts = ResolveOptions::new(Direction::Response, "read");
assert!(validate(&schema, &good, &opts).is_ok());
let bad = json!({
"ucp": { "capabilities": { "dev.ucp.shopping.checkout": [
{ "version": "2026-04-08", "schema": "https://ucp.dev/schemas/shopping/checkout.json" } ] } }
});
assert!(matches!(
validate(&schema, &bad, &opts),
Err(ValidateError::Invalid { .. })
));
}
#[test]
fn non_mirroring_container_extension_is_rejected() {
let dir = tempfile::tempdir().unwrap();
write_fixtures(dir.path());
let cfg = config(dir.path());
let payload = json!({
"ucp": { "capabilities": {
"dev.ucp.shopping.catalog.search": [
{ "version": "2026-04-08", "schema": "https://ucp.dev/schemas/shopping/catalog_search.json" } ],
"dev.ucp.shopping.loyalty": [
{ "version": "2026-04-08", "schema": "https://ucp.dev/schemas/shopping/loyalty.json", "extends": "dev.ucp.shopping.catalog.search" } ]
} },
"products": []
});
let result = compose_from_payload(&payload, &cfg);
assert!(
result.is_err(),
"a container extension that doesn't mirror operation keys must be rejected, got {:?}",
result
);
}
#[test]
fn select_is_noop_for_single_object() {
let schema = json!({ "type": "object", "properties": { "id": { "type": "string" } } });
let opts = ResolveOptions::new(Direction::Response, "read");
let selected = select_operation_schema(&schema, &opts).unwrap();
assert_eq!(
selected, schema,
"single-object schemas pass through unchanged"
);
}
#[test]
fn select_wraps_container_with_ref_and_defs() {
let schema = json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"$defs": {
"search_request": { "type": "object" },
"search_response": { "type": "object", "required": ["products"] }
}
});
let opts = ResolveOptions::new(Direction::Response, "search");
let selected = select_operation_schema(&schema, &opts).unwrap();
assert_eq!(selected["$ref"], "#/$defs/search_response");
assert!(selected["$defs"]["search_response"].is_object());
assert!(
selected["$defs"]["search_request"].is_object(),
"sibling defs retained"
);
}
#[test]
fn explicit_def_selects_subtype_on_schema_with_body() {
let schema = json!({
"type": "object",
"required": ["id"],
"properties": { "id": { "type": "string" } },
"$defs": { "checkout": { "type": "object", "required": ["token"] } }
});
let opts =
ResolveOptions::new(Direction::Request, "create").def_name(Some("checkout".to_string()));
let selected = select_operation_schema(&schema, &opts).unwrap();
assert_eq!(selected["$ref"], "#/$defs/checkout");
}
#[test]
fn explicit_def_overrides_derivation() {
let schema = json!({
"type": "object",
"$defs": {
"search_request": { "type": "object", "required": ["query"] },
"search_response": { "type": "object", "required": ["products"] }
}
});
let opts = ResolveOptions::new(Direction::Response, "search")
.def_name(Some("search_request".to_string()));
let selected = select_operation_schema(&schema, &opts).unwrap();
assert_eq!(selected["$ref"], "#/$defs/search_request");
}
#[test]
fn explicit_def_missing_is_loud() {
let schema = json!({ "type": "object", "$defs": { "a": {}, "b": {} } });
let opts = ResolveOptions::new(Direction::Request, "create").def_name(Some("nope".to_string()));
let err = select_operation_schema(&schema, &opts).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("nope") && msg.contains('a') && msg.contains('b'),
"got: {msg}"
);
}
#[test]
fn explicit_def_validates_fragment_end_to_end() {
let dir = tempfile::tempdir().unwrap();
write_fixtures(dir.path());
let cfg = config(dir.path());
let payload = json!({
"ucp": { "capabilities": { "dev.ucp.shopping.cart": [
{ "version": "2026-04-08", "schema": "https://ucp.dev/schemas/shopping/cart.json" } ] } },
"token": "ok"
});
let schema = compose_from_payload(&payload, &cfg).unwrap();
let root_opts = ResolveOptions::new(Direction::Request, "create");
assert!(matches!(
validate(&schema, &payload, &root_opts),
Err(ValidateError::Invalid { .. })
));
let def_opts =
ResolveOptions::new(Direction::Request, "create").def_name(Some("checkout".to_string()));
assert!(validate(&schema, &payload, &def_opts).is_ok());
}