decision_agent 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +1060 -0
- data/bin/decision_agent +104 -0
- data/lib/decision_agent/agent.rb +147 -0
- data/lib/decision_agent/audit/adapter.rb +9 -0
- data/lib/decision_agent/audit/logger_adapter.rb +27 -0
- data/lib/decision_agent/audit/null_adapter.rb +8 -0
- data/lib/decision_agent/context.rb +42 -0
- data/lib/decision_agent/decision.rb +51 -0
- data/lib/decision_agent/dsl/condition_evaluator.rb +133 -0
- data/lib/decision_agent/dsl/rule_parser.rb +36 -0
- data/lib/decision_agent/dsl/schema_validator.rb +275 -0
- data/lib/decision_agent/errors.rb +62 -0
- data/lib/decision_agent/evaluation.rb +52 -0
- data/lib/decision_agent/evaluators/base.rb +15 -0
- data/lib/decision_agent/evaluators/json_rule_evaluator.rb +51 -0
- data/lib/decision_agent/evaluators/static_evaluator.rb +31 -0
- data/lib/decision_agent/replay/replay.rb +147 -0
- data/lib/decision_agent/scoring/base.rb +19 -0
- data/lib/decision_agent/scoring/consensus.rb +40 -0
- data/lib/decision_agent/scoring/max_weight.rb +16 -0
- data/lib/decision_agent/scoring/threshold.rb +40 -0
- data/lib/decision_agent/scoring/weighted_average.rb +26 -0
- data/lib/decision_agent/version.rb +3 -0
- data/lib/decision_agent/web/public/app.js +580 -0
- data/lib/decision_agent/web/public/index.html +190 -0
- data/lib/decision_agent/web/public/styles.css +558 -0
- data/lib/decision_agent/web/server.rb +255 -0
- data/lib/decision_agent.rb +29 -0
- data/spec/agent_spec.rb +249 -0
- data/spec/api_contract_spec.rb +430 -0
- data/spec/audit_adapters_spec.rb +74 -0
- data/spec/comprehensive_edge_cases_spec.rb +1777 -0
- data/spec/context_spec.rb +84 -0
- data/spec/dsl_validation_spec.rb +648 -0
- data/spec/edge_cases_spec.rb +353 -0
- data/spec/examples/feedback_aware_evaluator_spec.rb +460 -0
- data/spec/json_rule_evaluator_spec.rb +587 -0
- data/spec/replay_edge_cases_spec.rb +699 -0
- data/spec/replay_spec.rb +210 -0
- data/spec/scoring_spec.rb +225 -0
- data/spec/spec_helper.rb +28 -0
- metadata +133 -0
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
require "spec_helper"
|
|
2
|
+
|
|
3
|
+
RSpec.describe "DSL Validation" do
|
|
4
|
+
describe DecisionAgent::Dsl::SchemaValidator do
|
|
5
|
+
describe "root structure validation" do
|
|
6
|
+
it "rejects non-hash input" do
|
|
7
|
+
expect {
|
|
8
|
+
DecisionAgent::Dsl::SchemaValidator.validate!([1, 2, 3])
|
|
9
|
+
}.to raise_error(DecisionAgent::InvalidRuleDslError, /Root element must be a hash/)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
it "rejects string input" do
|
|
13
|
+
expect {
|
|
14
|
+
DecisionAgent::Dsl::SchemaValidator.validate!("not a hash")
|
|
15
|
+
}.to raise_error(DecisionAgent::InvalidRuleDslError, /Root element must be a hash/)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it "accepts valid hash input" do
|
|
19
|
+
valid_rules = {
|
|
20
|
+
"version" => "1.0",
|
|
21
|
+
"rules" => []
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
expect {
|
|
25
|
+
DecisionAgent::Dsl::SchemaValidator.validate!(valid_rules)
|
|
26
|
+
}.not_to raise_error
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
describe "version validation" do
|
|
31
|
+
it "requires version field" do
|
|
32
|
+
rules = {
|
|
33
|
+
"rules" => []
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
expect {
|
|
37
|
+
DecisionAgent::Dsl::SchemaValidator.validate!(rules)
|
|
38
|
+
}.to raise_error(DecisionAgent::InvalidRuleDslError, /Missing required field 'version'/)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it "accepts version as symbol key" do
|
|
42
|
+
rules = {
|
|
43
|
+
version: "1.0",
|
|
44
|
+
rules: []
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
expect {
|
|
48
|
+
DecisionAgent::Dsl::SchemaValidator.validate!(rules)
|
|
49
|
+
}.not_to raise_error
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
describe "rules array validation" do
|
|
54
|
+
it "requires rules field" do
|
|
55
|
+
rules = {
|
|
56
|
+
"version" => "1.0"
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
expect {
|
|
60
|
+
DecisionAgent::Dsl::SchemaValidator.validate!(rules)
|
|
61
|
+
}.to raise_error(DecisionAgent::InvalidRuleDslError, /Missing required field 'rules'/)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it "rejects non-array rules" do
|
|
65
|
+
rules = {
|
|
66
|
+
"version" => "1.0",
|
|
67
|
+
"rules" => "not an array"
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
expect {
|
|
71
|
+
DecisionAgent::Dsl::SchemaValidator.validate!(rules)
|
|
72
|
+
}.to raise_error(DecisionAgent::InvalidRuleDslError, /must be an array/)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it "accepts empty rules array" do
|
|
76
|
+
rules = {
|
|
77
|
+
"version" => "1.0",
|
|
78
|
+
"rules" => []
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
expect {
|
|
82
|
+
DecisionAgent::Dsl::SchemaValidator.validate!(rules)
|
|
83
|
+
}.not_to raise_error
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
describe "rule structure validation" do
|
|
88
|
+
it "rejects non-hash rule" do
|
|
89
|
+
rules = {
|
|
90
|
+
"version" => "1.0",
|
|
91
|
+
"rules" => ["not a hash"]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
expect {
|
|
95
|
+
DecisionAgent::Dsl::SchemaValidator.validate!(rules)
|
|
96
|
+
}.to raise_error(DecisionAgent::InvalidRuleDslError, /rules\[0\].*must be a hash/)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it "requires rule id" do
|
|
100
|
+
rules = {
|
|
101
|
+
"version" => "1.0",
|
|
102
|
+
"rules" => [
|
|
103
|
+
{
|
|
104
|
+
"if" => { "field" => "status", "op" => "eq", "value" => "active" },
|
|
105
|
+
"then" => { "decision" => "approve" }
|
|
106
|
+
}
|
|
107
|
+
]
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
expect {
|
|
111
|
+
DecisionAgent::Dsl::SchemaValidator.validate!(rules)
|
|
112
|
+
}.to raise_error(DecisionAgent::InvalidRuleDslError, /Missing required field 'id'/)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it "requires rule if clause" do
|
|
116
|
+
rules = {
|
|
117
|
+
"version" => "1.0",
|
|
118
|
+
"rules" => [
|
|
119
|
+
{
|
|
120
|
+
"id" => "rule_1",
|
|
121
|
+
"then" => { "decision" => "approve" }
|
|
122
|
+
}
|
|
123
|
+
]
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
expect {
|
|
127
|
+
DecisionAgent::Dsl::SchemaValidator.validate!(rules)
|
|
128
|
+
}.to raise_error(DecisionAgent::InvalidRuleDslError, /Missing required field 'if'/)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
it "requires rule then clause" do
|
|
132
|
+
rules = {
|
|
133
|
+
"version" => "1.0",
|
|
134
|
+
"rules" => [
|
|
135
|
+
{
|
|
136
|
+
"id" => "rule_1",
|
|
137
|
+
"if" => { "field" => "status", "op" => "eq", "value" => "active" }
|
|
138
|
+
}
|
|
139
|
+
]
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
expect {
|
|
143
|
+
DecisionAgent::Dsl::SchemaValidator.validate!(rules)
|
|
144
|
+
}.to raise_error(DecisionAgent::InvalidRuleDslError, /Missing required field 'then'/)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
describe "condition validation" do
|
|
149
|
+
it "rejects condition without field, all, or any" do
|
|
150
|
+
rules = {
|
|
151
|
+
"version" => "1.0",
|
|
152
|
+
"rules" => [
|
|
153
|
+
{
|
|
154
|
+
"id" => "rule_1",
|
|
155
|
+
"if" => { "invalid" => "condition" },
|
|
156
|
+
"then" => { "decision" => "approve" }
|
|
157
|
+
}
|
|
158
|
+
]
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
expect {
|
|
162
|
+
DecisionAgent::Dsl::SchemaValidator.validate!(rules)
|
|
163
|
+
}.to raise_error(DecisionAgent::InvalidRuleDslError, /Condition must have one of: 'field', 'all', or 'any'/)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
it "rejects non-hash condition" do
|
|
167
|
+
rules = {
|
|
168
|
+
"version" => "1.0",
|
|
169
|
+
"rules" => [
|
|
170
|
+
{
|
|
171
|
+
"id" => "rule_1",
|
|
172
|
+
"if" => "not a hash",
|
|
173
|
+
"then" => { "decision" => "approve" }
|
|
174
|
+
}
|
|
175
|
+
]
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
expect {
|
|
179
|
+
DecisionAgent::Dsl::SchemaValidator.validate!(rules)
|
|
180
|
+
}.to raise_error(DecisionAgent::InvalidRuleDslError, /Condition must be a hash/)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
describe "field condition validation" do
|
|
185
|
+
it "requires field key" do
|
|
186
|
+
rules = {
|
|
187
|
+
"version" => "1.0",
|
|
188
|
+
"rules" => [
|
|
189
|
+
{
|
|
190
|
+
"id" => "rule_1",
|
|
191
|
+
"if" => { "op" => "eq", "value" => "active" },
|
|
192
|
+
"then" => { "decision" => "approve" }
|
|
193
|
+
}
|
|
194
|
+
]
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
expect {
|
|
198
|
+
DecisionAgent::Dsl::SchemaValidator.validate!(rules)
|
|
199
|
+
}.to raise_error(DecisionAgent::InvalidRuleDslError) do |error|
|
|
200
|
+
expect(error.message).to match(/Condition must have one of: 'field', 'all', or 'any'/)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
it "requires op (operator) key" do
|
|
205
|
+
rules = {
|
|
206
|
+
"version" => "1.0",
|
|
207
|
+
"rules" => [
|
|
208
|
+
{
|
|
209
|
+
"id" => "rule_1",
|
|
210
|
+
"if" => { "field" => "status", "value" => "active" },
|
|
211
|
+
"then" => { "decision" => "approve" }
|
|
212
|
+
}
|
|
213
|
+
]
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
expect {
|
|
217
|
+
DecisionAgent::Dsl::SchemaValidator.validate!(rules)
|
|
218
|
+
}.to raise_error(DecisionAgent::InvalidRuleDslError, /missing 'op'/)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
it "validates operator is supported" do
|
|
222
|
+
rules = {
|
|
223
|
+
"version" => "1.0",
|
|
224
|
+
"rules" => [
|
|
225
|
+
{
|
|
226
|
+
"id" => "rule_1",
|
|
227
|
+
"if" => { "field" => "status", "op" => "invalid_op", "value" => "active" },
|
|
228
|
+
"then" => { "decision" => "approve" }
|
|
229
|
+
}
|
|
230
|
+
]
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
expect {
|
|
234
|
+
DecisionAgent::Dsl::SchemaValidator.validate!(rules)
|
|
235
|
+
}.to raise_error(DecisionAgent::InvalidRuleDslError) do |error|
|
|
236
|
+
expect(error.message).to include("Unsupported operator 'invalid_op'")
|
|
237
|
+
expect(error.message).to include("eq, neq, gt, gte, lt, lte, in, present, blank")
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
it "requires value for non-present/blank operators" do
|
|
242
|
+
rules = {
|
|
243
|
+
"version" => "1.0",
|
|
244
|
+
"rules" => [
|
|
245
|
+
{
|
|
246
|
+
"id" => "rule_1",
|
|
247
|
+
"if" => { "field" => "status", "op" => "eq" },
|
|
248
|
+
"then" => { "decision" => "approve" }
|
|
249
|
+
}
|
|
250
|
+
]
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
expect {
|
|
254
|
+
DecisionAgent::Dsl::SchemaValidator.validate!(rules)
|
|
255
|
+
}.to raise_error(DecisionAgent::InvalidRuleDslError, /missing 'value' key/)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
it "allows missing value for present operator" do
|
|
259
|
+
rules = {
|
|
260
|
+
"version" => "1.0",
|
|
261
|
+
"rules" => [
|
|
262
|
+
{
|
|
263
|
+
"id" => "rule_1",
|
|
264
|
+
"if" => { "field" => "assignee", "op" => "present" },
|
|
265
|
+
"then" => { "decision" => "assigned" }
|
|
266
|
+
}
|
|
267
|
+
]
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
expect {
|
|
271
|
+
DecisionAgent::Dsl::SchemaValidator.validate!(rules)
|
|
272
|
+
}.not_to raise_error
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
it "allows missing value for blank operator" do
|
|
276
|
+
rules = {
|
|
277
|
+
"version" => "1.0",
|
|
278
|
+
"rules" => [
|
|
279
|
+
{
|
|
280
|
+
"id" => "rule_1",
|
|
281
|
+
"if" => { "field" => "description", "op" => "blank" },
|
|
282
|
+
"then" => { "decision" => "needs_info" }
|
|
283
|
+
}
|
|
284
|
+
]
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
expect {
|
|
288
|
+
DecisionAgent::Dsl::SchemaValidator.validate!(rules)
|
|
289
|
+
}.not_to raise_error
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
it "rejects empty field path" do
|
|
293
|
+
rules = {
|
|
294
|
+
"version" => "1.0",
|
|
295
|
+
"rules" => [
|
|
296
|
+
{
|
|
297
|
+
"id" => "rule_1",
|
|
298
|
+
"if" => { "field" => "", "op" => "eq", "value" => "test" },
|
|
299
|
+
"then" => { "decision" => "approve" }
|
|
300
|
+
}
|
|
301
|
+
]
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
expect {
|
|
305
|
+
DecisionAgent::Dsl::SchemaValidator.validate!(rules)
|
|
306
|
+
}.to raise_error(DecisionAgent::InvalidRuleDslError, /Field path cannot be empty/)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
it "rejects invalid dot-notation" do
|
|
310
|
+
rules = {
|
|
311
|
+
"version" => "1.0",
|
|
312
|
+
"rules" => [
|
|
313
|
+
{
|
|
314
|
+
"id" => "rule_1",
|
|
315
|
+
"if" => { "field" => "user..role", "op" => "eq", "value" => "admin" },
|
|
316
|
+
"then" => { "decision" => "approve" }
|
|
317
|
+
}
|
|
318
|
+
]
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
expect {
|
|
322
|
+
DecisionAgent::Dsl::SchemaValidator.validate!(rules)
|
|
323
|
+
}.to raise_error(DecisionAgent::InvalidRuleDslError, /cannot have empty segments/)
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
it "accepts valid dot-notation" do
|
|
327
|
+
rules = {
|
|
328
|
+
"version" => "1.0",
|
|
329
|
+
"rules" => [
|
|
330
|
+
{
|
|
331
|
+
"id" => "rule_1",
|
|
332
|
+
"if" => { "field" => "user.profile.role", "op" => "eq", "value" => "admin" },
|
|
333
|
+
"then" => { "decision" => "allow" }
|
|
334
|
+
}
|
|
335
|
+
]
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
expect {
|
|
339
|
+
DecisionAgent::Dsl::SchemaValidator.validate!(rules)
|
|
340
|
+
}.not_to raise_error
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
describe "all/any condition validation" do
|
|
345
|
+
it "requires array for all condition" do
|
|
346
|
+
rules = {
|
|
347
|
+
"version" => "1.0",
|
|
348
|
+
"rules" => [
|
|
349
|
+
{
|
|
350
|
+
"id" => "rule_1",
|
|
351
|
+
"if" => { "all" => "not an array" },
|
|
352
|
+
"then" => { "decision" => "approve" }
|
|
353
|
+
}
|
|
354
|
+
]
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
expect {
|
|
358
|
+
DecisionAgent::Dsl::SchemaValidator.validate!(rules)
|
|
359
|
+
}.to raise_error(DecisionAgent::InvalidRuleDslError, /'all' condition must contain an array/)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
it "requires array for any condition" do
|
|
363
|
+
rules = {
|
|
364
|
+
"version" => "1.0",
|
|
365
|
+
"rules" => [
|
|
366
|
+
{
|
|
367
|
+
"id" => "rule_1",
|
|
368
|
+
"if" => { "any" => "not an array" },
|
|
369
|
+
"then" => { "decision" => "approve" }
|
|
370
|
+
}
|
|
371
|
+
]
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
expect {
|
|
375
|
+
DecisionAgent::Dsl::SchemaValidator.validate!(rules)
|
|
376
|
+
}.to raise_error(DecisionAgent::InvalidRuleDslError, /'any' condition must contain an array/)
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
it "validates nested conditions in all" do
|
|
380
|
+
rules = {
|
|
381
|
+
"version" => "1.0",
|
|
382
|
+
"rules" => [
|
|
383
|
+
{
|
|
384
|
+
"id" => "rule_1",
|
|
385
|
+
"if" => {
|
|
386
|
+
"all" => [
|
|
387
|
+
{ "field" => "status", "op" => "invalid_op", "value" => "active" }
|
|
388
|
+
]
|
|
389
|
+
},
|
|
390
|
+
"then" => { "decision" => "approve" }
|
|
391
|
+
}
|
|
392
|
+
]
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
expect {
|
|
396
|
+
DecisionAgent::Dsl::SchemaValidator.validate!(rules)
|
|
397
|
+
}.to raise_error(DecisionAgent::InvalidRuleDslError, /Unsupported operator/)
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
it "validates nested conditions in any" do
|
|
401
|
+
rules = {
|
|
402
|
+
"version" => "1.0",
|
|
403
|
+
"rules" => [
|
|
404
|
+
{
|
|
405
|
+
"id" => "rule_1",
|
|
406
|
+
"if" => {
|
|
407
|
+
"any" => [
|
|
408
|
+
{ "field" => "priority" } # Missing op
|
|
409
|
+
]
|
|
410
|
+
},
|
|
411
|
+
"then" => { "decision" => "escalate" }
|
|
412
|
+
}
|
|
413
|
+
]
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
expect {
|
|
417
|
+
DecisionAgent::Dsl::SchemaValidator.validate!(rules)
|
|
418
|
+
}.to raise_error(DecisionAgent::InvalidRuleDslError, /missing 'op'/)
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
describe "then clause validation" do
|
|
423
|
+
it "requires then clause to be a hash" do
|
|
424
|
+
rules = {
|
|
425
|
+
"version" => "1.0",
|
|
426
|
+
"rules" => [
|
|
427
|
+
{
|
|
428
|
+
"id" => "rule_1",
|
|
429
|
+
"if" => { "field" => "status", "op" => "eq", "value" => "active" },
|
|
430
|
+
"then" => "not a hash"
|
|
431
|
+
}
|
|
432
|
+
]
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
expect {
|
|
436
|
+
DecisionAgent::Dsl::SchemaValidator.validate!(rules)
|
|
437
|
+
}.to raise_error(DecisionAgent::InvalidRuleDslError, /then.*Must be a hash/)
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
it "requires decision field in then clause" do
|
|
441
|
+
rules = {
|
|
442
|
+
"version" => "1.0",
|
|
443
|
+
"rules" => [
|
|
444
|
+
{
|
|
445
|
+
"id" => "rule_1",
|
|
446
|
+
"if" => { "field" => "status", "op" => "eq", "value" => "active" },
|
|
447
|
+
"then" => { "weight" => 0.8 }
|
|
448
|
+
}
|
|
449
|
+
]
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
expect {
|
|
453
|
+
DecisionAgent::Dsl::SchemaValidator.validate!(rules)
|
|
454
|
+
}.to raise_error(DecisionAgent::InvalidRuleDslError, /Missing required field 'decision'/)
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
it "validates weight is numeric" do
|
|
458
|
+
rules = {
|
|
459
|
+
"version" => "1.0",
|
|
460
|
+
"rules" => [
|
|
461
|
+
{
|
|
462
|
+
"id" => "rule_1",
|
|
463
|
+
"if" => { "field" => "status", "op" => "eq", "value" => "active" },
|
|
464
|
+
"then" => { "decision" => "approve", "weight" => "not a number" }
|
|
465
|
+
}
|
|
466
|
+
]
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
expect {
|
|
470
|
+
DecisionAgent::Dsl::SchemaValidator.validate!(rules)
|
|
471
|
+
}.to raise_error(DecisionAgent::InvalidRuleDslError, /weight.*Must be a number/)
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
it "validates weight is between 0 and 1" do
|
|
475
|
+
rules = {
|
|
476
|
+
"version" => "1.0",
|
|
477
|
+
"rules" => [
|
|
478
|
+
{
|
|
479
|
+
"id" => "rule_1",
|
|
480
|
+
"if" => { "field" => "status", "op" => "eq", "value" => "active" },
|
|
481
|
+
"then" => { "decision" => "approve", "weight" => 1.5 }
|
|
482
|
+
}
|
|
483
|
+
]
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
expect {
|
|
487
|
+
DecisionAgent::Dsl::SchemaValidator.validate!(rules)
|
|
488
|
+
}.to raise_error(DecisionAgent::InvalidRuleDslError, /weight.*between 0.0 and 1.0/)
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
it "validates reason is a string" do
|
|
492
|
+
rules = {
|
|
493
|
+
"version" => "1.0",
|
|
494
|
+
"rules" => [
|
|
495
|
+
{
|
|
496
|
+
"id" => "rule_1",
|
|
497
|
+
"if" => { "field" => "status", "op" => "eq", "value" => "active" },
|
|
498
|
+
"then" => { "decision" => "approve", "reason" => 123 }
|
|
499
|
+
}
|
|
500
|
+
]
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
expect {
|
|
504
|
+
DecisionAgent::Dsl::SchemaValidator.validate!(rules)
|
|
505
|
+
}.to raise_error(DecisionAgent::InvalidRuleDslError, /reason.*Must be a string/)
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
it "accepts valid then clause with all fields" do
|
|
509
|
+
rules = {
|
|
510
|
+
"version" => "1.0",
|
|
511
|
+
"rules" => [
|
|
512
|
+
{
|
|
513
|
+
"id" => "rule_1",
|
|
514
|
+
"if" => { "field" => "status", "op" => "eq", "value" => "active" },
|
|
515
|
+
"then" => {
|
|
516
|
+
"decision" => "approve",
|
|
517
|
+
"weight" => 0.8,
|
|
518
|
+
"reason" => "Status is active"
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
]
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
expect {
|
|
525
|
+
DecisionAgent::Dsl::SchemaValidator.validate!(rules)
|
|
526
|
+
}.not_to raise_error
|
|
527
|
+
end
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
describe "error message formatting" do
|
|
531
|
+
it "provides numbered error list for multiple errors" do
|
|
532
|
+
rules = {
|
|
533
|
+
"version" => "1.0",
|
|
534
|
+
"rules" => [
|
|
535
|
+
{
|
|
536
|
+
"id" => "rule_1",
|
|
537
|
+
# Missing if clause
|
|
538
|
+
# Missing then clause
|
|
539
|
+
}
|
|
540
|
+
]
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
expect {
|
|
544
|
+
DecisionAgent::Dsl::SchemaValidator.validate!(rules)
|
|
545
|
+
}.to raise_error(DecisionAgent::InvalidRuleDslError) do |error|
|
|
546
|
+
expect(error.message).to include("1.")
|
|
547
|
+
expect(error.message).to include("2.")
|
|
548
|
+
expect(error.message).to match(/validation failed with 2 errors/)
|
|
549
|
+
end
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
it "includes helpful context in error messages" do
|
|
553
|
+
rules = {
|
|
554
|
+
"version" => "1.0",
|
|
555
|
+
"rules" => [
|
|
556
|
+
{
|
|
557
|
+
"id" => "rule_1",
|
|
558
|
+
"if" => { "field" => "status", "op" => "invalid_op", "value" => "test" },
|
|
559
|
+
"then" => { "decision" => "approve" }
|
|
560
|
+
}
|
|
561
|
+
]
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
expect {
|
|
565
|
+
DecisionAgent::Dsl::SchemaValidator.validate!(rules)
|
|
566
|
+
}.to raise_error(DecisionAgent::InvalidRuleDslError) do |error|
|
|
567
|
+
expect(error.message).to include("rules[0].if")
|
|
568
|
+
expect(error.message).to include("Supported operators:")
|
|
569
|
+
end
|
|
570
|
+
end
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
describe "complex nested validation" do
|
|
574
|
+
it "validates deeply nested all/any structures" do
|
|
575
|
+
rules = {
|
|
576
|
+
"version" => "1.0",
|
|
577
|
+
"rules" => [
|
|
578
|
+
{
|
|
579
|
+
"id" => "rule_1",
|
|
580
|
+
"if" => {
|
|
581
|
+
"all" => [
|
|
582
|
+
{
|
|
583
|
+
"any" => [
|
|
584
|
+
{ "field" => "a", "op" => "eq", "value" => 1 },
|
|
585
|
+
{ "field" => "b", "op" => "invalid_op", "value" => 2 }
|
|
586
|
+
]
|
|
587
|
+
}
|
|
588
|
+
]
|
|
589
|
+
},
|
|
590
|
+
"then" => { "decision" => "approve" }
|
|
591
|
+
}
|
|
592
|
+
]
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
expect {
|
|
596
|
+
DecisionAgent::Dsl::SchemaValidator.validate!(rules)
|
|
597
|
+
}.to raise_error(DecisionAgent::InvalidRuleDslError) do |error|
|
|
598
|
+
expect(error.message).to include("rules[0].if.all[0].any[1]")
|
|
599
|
+
expect(error.message).to include("Unsupported operator")
|
|
600
|
+
end
|
|
601
|
+
end
|
|
602
|
+
end
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
describe "RuleParser integration" do
|
|
606
|
+
it "uses SchemaValidator for validation" do
|
|
607
|
+
invalid_json = '{"version": "1.0", "rules": "not an array"}'
|
|
608
|
+
|
|
609
|
+
expect {
|
|
610
|
+
DecisionAgent::Dsl::RuleParser.parse(invalid_json)
|
|
611
|
+
}.to raise_error(DecisionAgent::InvalidRuleDslError, /must be an array/)
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
it "provides helpful error for malformed JSON" do
|
|
615
|
+
malformed_json = '{"version": "1.0", "rules": [,,,]}'
|
|
616
|
+
|
|
617
|
+
expect {
|
|
618
|
+
DecisionAgent::Dsl::RuleParser.parse(malformed_json)
|
|
619
|
+
}.to raise_error(DecisionAgent::InvalidRuleDslError) do |error|
|
|
620
|
+
expect(error.message).to include("Invalid JSON syntax")
|
|
621
|
+
expect(error.message).to include("Common issues")
|
|
622
|
+
end
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
it "accepts hash input" do
|
|
626
|
+
rules_hash = {
|
|
627
|
+
version: "1.0",
|
|
628
|
+
rules: [
|
|
629
|
+
{
|
|
630
|
+
id: "rule_1",
|
|
631
|
+
if: { field: "status", op: "eq", value: "active" },
|
|
632
|
+
then: { decision: "approve" }
|
|
633
|
+
}
|
|
634
|
+
]
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
expect {
|
|
638
|
+
DecisionAgent::Dsl::RuleParser.parse(rules_hash)
|
|
639
|
+
}.not_to raise_error
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
it "rejects invalid input types" do
|
|
643
|
+
expect {
|
|
644
|
+
DecisionAgent::Dsl::RuleParser.parse(12345)
|
|
645
|
+
}.to raise_error(DecisionAgent::InvalidRuleDslError, /Expected JSON string or Hash/)
|
|
646
|
+
end
|
|
647
|
+
end
|
|
648
|
+
end
|