use serde_json::json; use std::collections::HashMap; use std::sync::{Arc, Mutex}; use zen_engine::loader::{LoaderError, MemoryLoader}; use zen_engine::model::{DecisionContent, PolicyContent}; use zen_engine::{ DecisionEngine, EvaluationError, EvaluationOptions, EvaluationSerializedOptions, EvaluationTraceKind, }; mod support; use support::load_test_data; fn simple_policy_json() -> serde_json::Value { json!({ "blocks": [ { "id": "dm-customer", "type": "dataModel", "props": { "data": { "name": "customer", "properties": [ { "id": "p1", "name": "age", "type": "number", "array": false, "optional": false } ] } }, "children": [] }, { "id": "assert1", "type": "assertion", "props": { "data": { "output": "customer.isAdult", "conditions": [ { "id": "c1", "expression": "customer.age >= 18", "operator": "and", "depth": 0 } ] } }, "children": [] } ] }) } fn importing_policy_json(import_path: &str, output: &str) -> serde_json::Value { json!({ "imports": [import_path], "blocks": [ { "id": format!("assert-{output}"), "type": "assertion", "props": { "data": { "output": format!("customer.{output}"), "conditions": [ { "id": "c1", "expression": "customer.age >= 18", "operator": "and", "depth": 0 } ] } }, "children": [] } ] }) } fn make_policy_content(json: serde_json::Value) -> DecisionContent { let policy: zen_engine::policy::PolicyDocument = serde_json::from_value(json).expect("valid policy fixture"); DecisionContent::Policy(PolicyContent(Arc::new(policy))) } fn engine_with(loader: Arc) -> DecisionEngine { DecisionEngine::default().with_loader(loader) } #[tokio::test] async fn evaluate_single_policy() { let loader = Arc::new(MemoryLoader::default()); loader.add("policy", make_policy_content(simple_policy_json())); let engine = engine_with(loader); let result = engine .evaluate("policy", json!({ "customer": { "age": 30 } }).into()) .await .expect("evaluate ok"); let result_json: serde_json::Value = result.result.into(); assert_eq!( result_json.pointer("/customer/isAdult"), Some(&json!(true)), "expected customer.isAdult=true, got {result_json:#?}", ); } #[tokio::test] async fn evaluate_with_single_import() { let loader = Arc::new(MemoryLoader::default()); loader.add("base", make_policy_content(simple_policy_json())); loader.add( "entry", make_policy_content(importing_policy_json("base", "fromEntry")), ); let engine = engine_with(loader); let result = engine .evaluate("entry", json!({ "customer": { "age": 70 } }).into()) .await .expect("evaluate ok"); let result_json: serde_json::Value = result.result.into(); assert_eq!(result_json.pointer("/customer/isAdult"), Some(&json!(true))); assert_eq!( result_json.pointer("/customer/fromEntry"), Some(&json!(true)) ); } #[tokio::test] async fn evaluate_chained_imports() { let loader = Arc::new(MemoryLoader::default()); loader.add("base", make_policy_content(simple_policy_json())); loader.add( "middle", make_policy_content(importing_policy_json("base", "fromMiddle")), ); loader.add( "entry", make_policy_content(importing_policy_json("middle", "fromEntry")), ); let engine = engine_with(loader); let result = engine .evaluate("entry", json!({ "customer": { "age": 80 } }).into()) .await .expect("evaluate ok"); let result_json: serde_json::Value = result.result.into(); assert_eq!(result_json.pointer("/customer/isAdult"), Some(&json!(true))); assert_eq!( result_json.pointer("/customer/fromMiddle"), Some(&json!(true)) ); assert_eq!( result_json.pointer("/customer/fromEntry"), Some(&json!(true)) ); } #[tokio::test] async fn cycle_terminates() { let loader = Arc::new(MemoryLoader::default()); loader.add("a", make_policy_content(importing_policy_json("b", "aOut"))); loader.add("b", make_policy_content(importing_policy_json("a", "bOut"))); let engine = engine_with(loader); let result = engine .evaluate("a", json!({ "customer": { "age": 25 } }).into()) .await; let _ = result; } #[tokio::test] async fn self_import_terminates() { let loader = Arc::new(MemoryLoader::default()); loader.add( "a", make_policy_content(importing_policy_json("a", "selfOut")), ); let engine = engine_with(loader); let result = engine .evaluate("a", json!({ "customer": { "age": 25 } }).into()) .await; let _ = result; } #[tokio::test] async fn policy_importing_graph_fails() { let loader = Arc::new(MemoryLoader::default()); loader.add( "entry", make_policy_content(importing_policy_json("g", "fromEntry")), ); loader.add("g", load_test_data("table.json")); let engine = engine_with(loader); let err = engine .evaluate("entry", json!({ "customer": { "age": 25 } }).into()) .await .expect_err("policy importing a graph should fail"); match *err { EvaluationError::ContentKindMismatch { expected, got, .. } => { assert_eq!(expected, "policy"); assert_eq!(got, "graph"); } other => panic!("expected ContentKindMismatch, got: {other:?}"), } } #[tokio::test] async fn missing_import_is_loader_error() { let loader = Arc::new(MemoryLoader::default()); loader.add( "entry", make_policy_content(importing_policy_json("does-not-exist", "fromEntry")), ); let engine = engine_with(loader); let err = engine .evaluate("entry", json!({ "customer": { "age": 25 } }).into()) .await .expect_err("missing import should surface loader error"); assert!( matches!(*err, EvaluationError::LoaderError(_)), "expected LoaderError, got: {err:?}" ); } fn assert_string_trace(value: serde_json::Value) { let trace = value.pointer("/trace").expect("trace key present"); let trace_str = trace.as_str().expect("trace must serialize as a string"); let parsed: serde_json::Value = serde_json::from_str(trace_str).expect("trace string is JSON"); assert!(parsed.pointer("/properties").is_some()); assert!(parsed.pointer("/executions").is_some()); } #[tokio::test] async fn serialized_string_trace_for_policies() { let loader = Arc::new(MemoryLoader::default()); loader.add("policy", make_policy_content(simple_policy_json())); let engine = engine_with(loader); let options = EvaluationSerializedOptions { trace: EvaluationTraceKind::String, ..Default::default() }; let lazy = engine .evaluate_serialized( "policy", json!({ "customer": { "age": 30 } }).into(), options, ) .await .expect("evaluate ok"); assert_string_trace(lazy); engine.compile(); let precompiled = engine .evaluate_serialized( "policy", json!({ "customer": { "age": 30 } }).into(), options, ) .await .expect("evaluate ok"); assert_string_trace(precompiled); } #[tokio::test] async fn diamond_imports_load_each_key_once() { let mut contents: HashMap> = HashMap::new(); contents.insert( "entry".into(), Arc::new(make_policy_content(json!({ "imports": ["a", "b"], "blocks": [ { "id": "assert-entry", "type": "assertion", "props": { "data": { "output": "customer.fromEntry", "conditions": [ { "id": "c1", "expression": "customer.age >= 18", "operator": "and", "depth": 0 } ] } } } ] }))), ); contents.insert( "a".into(), Arc::new(make_policy_content(importing_policy_json("base", "fromA"))), ); contents.insert( "b".into(), Arc::new(make_policy_content(importing_policy_json("base", "fromB"))), ); contents.insert( "base".into(), Arc::new(make_policy_content(simple_policy_json())), ); let counts: Arc>> = Arc::new(Mutex::new(HashMap::new())); let counts_in_loader = counts.clone(); let engine = DecisionEngine::default().with_closure_loader(move |key| { let counts = counts_in_loader.clone(); let content = contents.get(&key).cloned(); async move { *counts.lock().unwrap().entry(key.clone()).or_insert(0) += 1; content.ok_or_else(|| LoaderError::NotFound(key)) } }); engine .evaluate("entry", json!({ "customer": { "age": 30 } }).into()) .await .expect("evaluate ok"); let counts = counts.lock().unwrap(); for key in ["entry", "a", "b", "base"] { assert_eq!( counts.get(key), Some(&1), "key {key} should load exactly once, got {counts:?}" ); } } #[tokio::test] async fn trace_serializes_for_policies() { let loader = Arc::new(MemoryLoader::default()); loader.add("policy", make_policy_content(simple_policy_json())); let engine = engine_with(loader); let result = engine .evaluate_with_opts( "policy", json!({ "customer": { "age": 30 } }).into(), EvaluationOptions { trace: true, ..Default::default() }, ) .await .expect("evaluate ok"); assert!(result.trace.is_some(), "trace requested but None returned"); let serialized = serde_json::to_value(&result).expect("serialize ok"); let trace = serialized.pointer("/trace").expect("trace key present"); assert!( trace.pointer("/properties").is_some(), "policy trace should serialize with `properties` field" ); assert!( trace.pointer("/executions").is_some(), "policy trace should serialize with `executions` field" ); } fn parent_declares_child_reads(child_condition: &str) -> Arc { let loader = Arc::new(MemoryLoader::default()); loader.add( "main", make_policy_content(json!({ "imports": ["shared"], "blocks": [ { "id": "dm", "type": "dataModel", "props": { "data": { "name": "customer", "properties": [ { "id": "p1", "name": "name", "type": "string", "array": false, "optional": false }, { "id": "p2", "name": "age", "type": "number", "array": false, "optional": false } ] } }, "children": [] }, { "id": "s1", "type": "expression", "props": { "data": { "key": "customer.discount", "value": "customer.tier" } } } ] })), ); loader.add( "shared", make_policy_content(json!({ "blocks": [ { "id": "m", "type": "match", "props": { "data": { "key": "customer.tier", "arms": [ { "id": "a1", "condition": child_condition, "value": "\"gold\"" }, { "id": "a2", "condition": "", "value": "\"silver\"" } ] } } } ] })), ); loader } #[tokio::test] async fn evaluate_child_reading_parent_declared_fields() { let engine = engine_with(parent_declares_child_reads("customer.age > 50")); let result = engine .evaluate( "main", json!({ "customer": { "name": "Ann", "age": 60 } }).into(), ) .await .expect("evaluate ok"); let result_json: serde_json::Value = result.result.into(); assert_eq!(result_json.pointer("/customer/tier"), Some(&json!("gold"))); assert_eq!( result_json.pointer("/customer/discount"), Some(&json!("gold")) ); } #[tokio::test] async fn evaluate_child_with_real_error_is_refused() { let engine = engine_with(parent_declares_child_reads("customer.missing > 50")); let result = engine .evaluate( "main", json!({ "customer": { "name": "Ann", "age": 60 } }).into(), ) .await; assert!( format!("{result:?}").contains("CompilationErrors"), "expected compilation error, got {result:?}" ); } #[tokio::test] async fn evaluate_refuses_child_block_mixed_under_parent_data_model() { let loader = Arc::new(MemoryLoader::default()); let data_model = |id: &str, name: &str| { json!({ "id": id, "type": "dataModel", "props": { "data": { "name": name, "properties": [ { "id": format!("{id}-p"), "name": "name", "type": "string", "array": false, "optional": false } ] } }, "children": [] }) }; loader.add( "main", make_policy_content(json!({ "imports": ["child"], "blocks": [data_model("dm-customer", "customer"), data_model("dm-company", "company")] })), ); loader.add( "child", make_policy_content(json!({ "blocks": [ { "id": "bad-block", "type": "decisionTable", "props": { "data": { "hitPolicy": "first", "inputs": [], "outputs": [ { "id": "s1", "name": "", "field": "customer.label" }, { "id": "s2", "name": "", "field": "company.label" } ], "rules": [{ "_id": "r1", "s1": "\"x\"", "s2": "\"y\"" }] } } } ] })), ); let engine = engine_with(loader); let result = engine .evaluate( "main", json!({ "customer": { "name": "a" }, "company": { "name": "b" } }).into(), ) .await; assert!( format!("{result:?}").contains("CompilationErrors"), "expected compilation error, got {result:?}" ); } #[tokio::test] async fn evaluate_refuses_child_unreachable_read_under_parent_data_model() { let loader = Arc::new(MemoryLoader::default()); loader.add( "main", make_policy_content(json!({ "imports": ["child"], "blocks": [ { "id": "dm-customer", "type": "dataModel", "props": { "data": { "name": "customer", "properties": [ { "id": "p1", "name": "companies", "type": "relationship", "target": "company", "array": true, "optional": false } ] } } }, { "id": "dm-company", "type": "dataModel", "props": { "data": { "name": "company", "properties": [ { "id": "p2", "name": "revenue", "type": "number", "array": false, "optional": false } ] } } } ] })), ); loader.add( "child", make_policy_content(json!({ "blocks": [ { "id": "assert", "type": "assertion", "props": { "data": { "output": "globalFlag", "conditions": [ { "id": "c1", "expression": "company.revenue > 0", "operator": "and", "depth": 0 } ] } } } ] })), ); let engine = engine_with(loader); let result = engine .evaluate( "main", json!({ "customer": { "companies": [{ "revenue": 5 }] } }).into(), ) .await; assert!( format!("{result:?}").contains("CompilationErrors"), "expected compilation error, got {result:?}" ); } #[tokio::test] async fn compile_accepts_child_reading_parent_declared_fields() { let engine = engine_with(parent_declares_child_reads("customer.age > 50")); let failures = engine.compile(); let reported: Vec<(&str, &str)> = failures.iter().map(|f| (f.key.as_ref(), f.kind)).collect(); assert_eq!( reported, vec![("shared", "policyImportOnly")], "{failures:#?}" ); let result = engine .evaluate( "main", json!({ "customer": { "name": "Ann", "age": 60 } }).into(), ) .await .expect("evaluate ok"); let result_json: serde_json::Value = result.result.into(); assert_eq!(result_json.pointer("/customer/tier"), Some(&json!("gold"))); } #[tokio::test] async fn compile_reports_child_with_real_error() { let engine = engine_with(parent_declares_child_reads("customer.missing > 50")); let failures = engine.compile(); let keys: Vec<&str> = failures.iter().map(|f| f.key.as_ref()).collect(); assert!(keys.contains(&"main"), "{failures:#?}"); } #[tokio::test] async fn unknown_relationship_target_in_import_refuses_importer() { let loader = Arc::new(MemoryLoader::default()); loader.add( "main", make_policy_content(json!({ "imports": ["child"], "blocks": [ { "id": "s1", "type": "expression", "props": { "data": { "key": "flag", "value": "true" } } } ] })), ); loader.add( "child", make_policy_content(json!({ "blocks": [ { "id": "dm", "type": "dataModel", "props": { "data": { "name": "customer", "properties": [ { "id": "p1", "name": "companies", "type": "relationship", "target": "company", "array": true, "optional": false } ] } }, "children": [] } ] })), ); let engine = engine_with(loader); let result = engine.evaluate("main", json!({}).into()).await; assert!( format!("{result:?}").contains("CompilationErrors"), "expected compilation error, got {result:?}" ); let failures = engine.compile(); let reported: Vec<(&str, &str)> = failures.iter().map(|f| (f.key.as_ref(), f.kind)).collect(); assert!(reported.contains(&("main", "policy")), "{failures:#?}"); assert!(reported.contains(&("child", "policy")), "{failures:#?}"); }