mirror of
https://github.com/gorules/zen.git
synced 2026-10-04 16:02:18 +00:00
355 lines
11 KiB
Rust
355 lines
11 KiB
Rust
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<MemoryLoader>) -> 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<String, Arc<DecisionContent>> = 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<Mutex<HashMap<String, usize>>> = 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"
|
|
);
|
|
}
|