decision_agent 0.1.1 → 0.1.3
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 +4 -4
- data/README.md +234 -919
- data/bin/decision_agent +5 -5
- data/lib/decision_agent/agent.rb +19 -26
- data/lib/decision_agent/audit/null_adapter.rb +1 -2
- data/lib/decision_agent/decision.rb +3 -1
- data/lib/decision_agent/dsl/condition_evaluator.rb +4 -3
- data/lib/decision_agent/dsl/rule_parser.rb +4 -6
- data/lib/decision_agent/dsl/schema_validator.rb +27 -31
- data/lib/decision_agent/errors.rb +21 -6
- data/lib/decision_agent/evaluation.rb +3 -1
- data/lib/decision_agent/evaluation_validator.rb +78 -0
- data/lib/decision_agent/evaluators/json_rule_evaluator.rb +26 -0
- data/lib/decision_agent/evaluators/static_evaluator.rb +2 -6
- data/lib/decision_agent/monitoring/alert_manager.rb +282 -0
- data/lib/decision_agent/monitoring/dashboard/public/dashboard.css +381 -0
- data/lib/decision_agent/monitoring/dashboard/public/dashboard.js +471 -0
- data/lib/decision_agent/monitoring/dashboard/public/index.html +161 -0
- data/lib/decision_agent/monitoring/dashboard_server.rb +340 -0
- data/lib/decision_agent/monitoring/metrics_collector.rb +278 -0
- data/lib/decision_agent/monitoring/monitored_agent.rb +71 -0
- data/lib/decision_agent/monitoring/prometheus_exporter.rb +247 -0
- data/lib/decision_agent/replay/replay.rb +12 -22
- data/lib/decision_agent/scoring/base.rb +1 -1
- data/lib/decision_agent/scoring/consensus.rb +5 -5
- data/lib/decision_agent/scoring/weighted_average.rb +1 -1
- data/lib/decision_agent/version.rb +1 -1
- data/lib/decision_agent/versioning/activerecord_adapter.rb +141 -0
- data/lib/decision_agent/versioning/adapter.rb +100 -0
- data/lib/decision_agent/versioning/file_storage_adapter.rb +290 -0
- data/lib/decision_agent/versioning/version_manager.rb +127 -0
- data/lib/decision_agent/web/public/app.js +318 -0
- data/lib/decision_agent/web/public/index.html +56 -1
- data/lib/decision_agent/web/public/styles.css +219 -0
- data/lib/decision_agent/web/server.rb +169 -9
- data/lib/decision_agent.rb +11 -0
- data/lib/generators/decision_agent/install/install_generator.rb +40 -0
- data/lib/generators/decision_agent/install/templates/README +47 -0
- data/lib/generators/decision_agent/install/templates/migration.rb +37 -0
- data/lib/generators/decision_agent/install/templates/rule.rb +30 -0
- data/lib/generators/decision_agent/install/templates/rule_version.rb +66 -0
- data/spec/activerecord_thread_safety_spec.rb +553 -0
- data/spec/agent_spec.rb +13 -13
- data/spec/api_contract_spec.rb +16 -16
- data/spec/audit_adapters_spec.rb +3 -3
- data/spec/comprehensive_edge_cases_spec.rb +86 -86
- data/spec/dsl_validation_spec.rb +83 -83
- data/spec/edge_cases_spec.rb +23 -23
- data/spec/examples/feedback_aware_evaluator_spec.rb +7 -7
- data/spec/examples.txt +548 -0
- data/spec/issue_verification_spec.rb +685 -0
- data/spec/json_rule_evaluator_spec.rb +15 -15
- data/spec/monitoring/alert_manager_spec.rb +378 -0
- data/spec/monitoring/metrics_collector_spec.rb +281 -0
- data/spec/monitoring/monitored_agent_spec.rb +222 -0
- data/spec/monitoring/prometheus_exporter_spec.rb +242 -0
- data/spec/replay_edge_cases_spec.rb +58 -58
- data/spec/replay_spec.rb +11 -11
- data/spec/rfc8785_canonicalization_spec.rb +215 -0
- data/spec/scoring_spec.rb +1 -1
- data/spec/spec_helper.rb +9 -0
- data/spec/thread_safety_spec.rb +482 -0
- data/spec/thread_safety_spec.rb.broken +878 -0
- data/spec/versioning_spec.rb +777 -0
- data/spec/web_ui_rack_spec.rb +135 -0
- metadata +84 -11
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
require "spec_helper"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
require "tempfile"
|
|
4
|
+
|
|
5
|
+
RSpec.describe "DecisionAgent Versioning System" do
|
|
6
|
+
describe DecisionAgent::Versioning::FileStorageAdapter do
|
|
7
|
+
let(:temp_dir) { Dir.mktmpdir }
|
|
8
|
+
let(:adapter) { described_class.new(storage_path: temp_dir) }
|
|
9
|
+
let(:rule_id) { "test_rule_001" }
|
|
10
|
+
let(:rule_content) do
|
|
11
|
+
{
|
|
12
|
+
version: "1.0",
|
|
13
|
+
ruleset: "test_ruleset",
|
|
14
|
+
rules: [
|
|
15
|
+
{
|
|
16
|
+
id: "rule_1",
|
|
17
|
+
if: { field: "amount", op: "gt", value: 100 },
|
|
18
|
+
then: { decision: "approve", weight: 0.8, reason: "High value" }
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
after do
|
|
25
|
+
FileUtils.rm_rf(temp_dir)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
describe "#create_version" do
|
|
29
|
+
it "creates a new version with version number 1" do
|
|
30
|
+
version = adapter.create_version(
|
|
31
|
+
rule_id: rule_id,
|
|
32
|
+
content: rule_content,
|
|
33
|
+
metadata: { created_by: "test_user", changelog: "Initial version" }
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
expect(version[:version_number]).to eq(1)
|
|
37
|
+
expect(version[:rule_id]).to eq(rule_id)
|
|
38
|
+
expect(version[:content]).to eq(rule_content)
|
|
39
|
+
expect(version[:created_by]).to eq("test_user")
|
|
40
|
+
expect(version[:changelog]).to eq("Initial version")
|
|
41
|
+
expect(version[:status]).to eq("active")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it "auto-increments version numbers" do
|
|
45
|
+
v1 = adapter.create_version(rule_id: rule_id, content: rule_content)
|
|
46
|
+
v2 = adapter.create_version(rule_id: rule_id, content: rule_content)
|
|
47
|
+
v3 = adapter.create_version(rule_id: rule_id, content: rule_content)
|
|
48
|
+
|
|
49
|
+
expect(v1[:version_number]).to eq(1)
|
|
50
|
+
expect(v2[:version_number]).to eq(2)
|
|
51
|
+
expect(v3[:version_number]).to eq(3)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it "deactivates previous active versions" do
|
|
55
|
+
v1 = adapter.create_version(rule_id: rule_id, content: rule_content)
|
|
56
|
+
v2 = adapter.create_version(rule_id: rule_id, content: rule_content)
|
|
57
|
+
|
|
58
|
+
versions = adapter.list_versions(rule_id: rule_id)
|
|
59
|
+
expect(versions.find { |v| v[:id] == v1[:id] }[:status]).to eq("archived")
|
|
60
|
+
expect(versions.find { |v| v[:id] == v2[:id] }[:status]).to eq("active")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it "persists versions to disk" do
|
|
64
|
+
version = adapter.create_version(rule_id: rule_id, content: rule_content)
|
|
65
|
+
|
|
66
|
+
# Create new adapter instance to verify persistence
|
|
67
|
+
new_adapter = described_class.new(storage_path: temp_dir)
|
|
68
|
+
loaded_version = new_adapter.get_version(version_id: version[:id])
|
|
69
|
+
|
|
70
|
+
expect(loaded_version).to eq(version)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
describe "#list_versions" do
|
|
75
|
+
it "returns empty array when no versions exist" do
|
|
76
|
+
versions = adapter.list_versions(rule_id: "nonexistent")
|
|
77
|
+
expect(versions).to eq([])
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
it "returns all versions for a rule ordered by version number descending" do
|
|
81
|
+
adapter.create_version(rule_id: rule_id, content: rule_content)
|
|
82
|
+
adapter.create_version(rule_id: rule_id, content: rule_content)
|
|
83
|
+
adapter.create_version(rule_id: rule_id, content: rule_content)
|
|
84
|
+
|
|
85
|
+
versions = adapter.list_versions(rule_id: rule_id)
|
|
86
|
+
|
|
87
|
+
expect(versions.map { |v| v[:version_number] }).to eq([3, 2, 1])
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
it "respects limit parameter" do
|
|
91
|
+
5.times { adapter.create_version(rule_id: rule_id, content: rule_content) }
|
|
92
|
+
|
|
93
|
+
versions = adapter.list_versions(rule_id: rule_id, limit: 2)
|
|
94
|
+
expect(versions.length).to eq(2)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
describe "#get_version" do
|
|
99
|
+
it "returns nil for nonexistent version" do
|
|
100
|
+
version = adapter.get_version(version_id: "nonexistent")
|
|
101
|
+
expect(version).to be_nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it "returns the correct version by ID" do
|
|
105
|
+
adapter.create_version(rule_id: rule_id, content: rule_content)
|
|
106
|
+
v2 = adapter.create_version(rule_id: rule_id, content: rule_content.merge(version: "2.0"))
|
|
107
|
+
|
|
108
|
+
loaded = adapter.get_version(version_id: v2[:id])
|
|
109
|
+
expect(loaded[:version_number]).to eq(2)
|
|
110
|
+
expect(loaded[:content][:version]).to eq("2.0")
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
describe "#get_version_by_number" do
|
|
115
|
+
it "returns the correct version by rule_id and version_number" do
|
|
116
|
+
adapter.create_version(rule_id: rule_id, content: rule_content)
|
|
117
|
+
v2 = adapter.create_version(rule_id: rule_id, content: rule_content.merge(version: "2.0"))
|
|
118
|
+
|
|
119
|
+
loaded = adapter.get_version_by_number(rule_id: rule_id, version_number: 2)
|
|
120
|
+
expect(loaded[:id]).to eq(v2[:id])
|
|
121
|
+
expect(loaded[:content][:version]).to eq("2.0")
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it "returns nil if version number doesn't exist" do
|
|
125
|
+
version = adapter.get_version_by_number(rule_id: rule_id, version_number: 999)
|
|
126
|
+
expect(version).to be_nil
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
describe "#get_active_version" do
|
|
131
|
+
it "returns nil when no active version exists" do
|
|
132
|
+
version = adapter.get_active_version(rule_id: "nonexistent")
|
|
133
|
+
expect(version).to be_nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
it "returns the currently active version" do
|
|
137
|
+
adapter.create_version(rule_id: rule_id, content: rule_content)
|
|
138
|
+
v2 = adapter.create_version(rule_id: rule_id, content: rule_content)
|
|
139
|
+
|
|
140
|
+
active = adapter.get_active_version(rule_id: rule_id)
|
|
141
|
+
expect(active[:id]).to eq(v2[:id])
|
|
142
|
+
expect(active[:status]).to eq("active")
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
describe "#activate_version" do
|
|
147
|
+
it "activates a version and deactivates others" do
|
|
148
|
+
v1 = adapter.create_version(rule_id: rule_id, content: rule_content)
|
|
149
|
+
v2 = adapter.create_version(rule_id: rule_id, content: rule_content)
|
|
150
|
+
|
|
151
|
+
adapter.activate_version(version_id: v1[:id])
|
|
152
|
+
|
|
153
|
+
versions = adapter.list_versions(rule_id: rule_id)
|
|
154
|
+
expect(versions.find { |v| v[:id] == v1[:id] }[:status]).to eq("active")
|
|
155
|
+
expect(versions.find { |v| v[:id] == v2[:id] }[:status]).to eq("archived")
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
it "raises error for nonexistent version" do
|
|
159
|
+
expect do
|
|
160
|
+
adapter.activate_version(version_id: "nonexistent")
|
|
161
|
+
end.to raise_error(DecisionAgent::NotFoundError)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
describe "#compare_versions" do
|
|
166
|
+
it "returns comparison with differences" do
|
|
167
|
+
content1 = rule_content
|
|
168
|
+
content2 = rule_content.merge(version: "2.0")
|
|
169
|
+
|
|
170
|
+
v1 = adapter.create_version(rule_id: rule_id, content: content1)
|
|
171
|
+
v2 = adapter.create_version(rule_id: rule_id, content: content2)
|
|
172
|
+
|
|
173
|
+
comparison = adapter.compare_versions(version_id_1: v1[:id], version_id_2: v2[:id])
|
|
174
|
+
|
|
175
|
+
expect(comparison[:version_1][:id]).to eq(v1[:id])
|
|
176
|
+
expect(comparison[:version_2][:id]).to eq(v2[:id])
|
|
177
|
+
expect(comparison[:differences]).to have_key(:added)
|
|
178
|
+
expect(comparison[:differences]).to have_key(:removed)
|
|
179
|
+
expect(comparison[:differences]).to have_key(:changed)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
it "returns nil if either version doesn't exist" do
|
|
183
|
+
v1 = adapter.create_version(rule_id: rule_id, content: rule_content)
|
|
184
|
+
|
|
185
|
+
comparison = adapter.compare_versions(version_id_1: v1[:id], version_id_2: "nonexistent")
|
|
186
|
+
expect(comparison).to be_nil
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
describe DecisionAgent::Versioning::VersionManager do
|
|
192
|
+
let(:temp_dir) { Dir.mktmpdir }
|
|
193
|
+
let(:adapter) { DecisionAgent::Versioning::FileStorageAdapter.new(storage_path: temp_dir) }
|
|
194
|
+
let(:manager) { described_class.new(adapter: adapter) }
|
|
195
|
+
let(:rule_id) { "test_rule_001" }
|
|
196
|
+
let(:rule_content) do
|
|
197
|
+
{
|
|
198
|
+
version: "1.0",
|
|
199
|
+
ruleset: "test_ruleset",
|
|
200
|
+
rules: [
|
|
201
|
+
{
|
|
202
|
+
id: "rule_1",
|
|
203
|
+
if: { field: "amount", op: "gt", value: 100 },
|
|
204
|
+
then: { decision: "approve", weight: 0.8, reason: "High value" }
|
|
205
|
+
}
|
|
206
|
+
]
|
|
207
|
+
}
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
after do
|
|
211
|
+
FileUtils.rm_rf(temp_dir)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
describe "#save_version" do
|
|
215
|
+
it "creates a version with metadata" do
|
|
216
|
+
version = manager.save_version(
|
|
217
|
+
rule_id: rule_id,
|
|
218
|
+
rule_content: rule_content,
|
|
219
|
+
created_by: "admin",
|
|
220
|
+
changelog: "Initial version"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
expect(version[:rule_id]).to eq(rule_id)
|
|
224
|
+
expect(version[:content]).to eq(rule_content)
|
|
225
|
+
expect(version[:created_by]).to eq("admin")
|
|
226
|
+
expect(version[:changelog]).to eq("Initial version")
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
it "validates rule content" do
|
|
230
|
+
expect do
|
|
231
|
+
manager.save_version(rule_id: rule_id, rule_content: nil)
|
|
232
|
+
end.to raise_error(DecisionAgent::ValidationError, /cannot be nil/)
|
|
233
|
+
|
|
234
|
+
expect do
|
|
235
|
+
manager.save_version(rule_id: rule_id, rule_content: "not a hash")
|
|
236
|
+
end.to raise_error(DecisionAgent::ValidationError, /must be a Hash/)
|
|
237
|
+
|
|
238
|
+
expect do
|
|
239
|
+
manager.save_version(rule_id: rule_id, rule_content: {})
|
|
240
|
+
end.to raise_error(DecisionAgent::ValidationError, /cannot be empty/)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
it "generates default changelog if not provided" do
|
|
244
|
+
version = manager.save_version(rule_id: rule_id, rule_content: rule_content)
|
|
245
|
+
expect(version[:changelog]).to match(/Version \d+/)
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
describe "#get_versions" do
|
|
250
|
+
it "returns all versions for a rule" do
|
|
251
|
+
3.times { manager.save_version(rule_id: rule_id, rule_content: rule_content) }
|
|
252
|
+
|
|
253
|
+
versions = manager.get_versions(rule_id: rule_id)
|
|
254
|
+
expect(versions.length).to eq(3)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
it "respects limit" do
|
|
258
|
+
5.times { manager.save_version(rule_id: rule_id, rule_content: rule_content) }
|
|
259
|
+
|
|
260
|
+
versions = manager.get_versions(rule_id: rule_id, limit: 2)
|
|
261
|
+
expect(versions.length).to eq(2)
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
describe "#rollback" do
|
|
266
|
+
it "activates a previous version without creating a duplicate" do
|
|
267
|
+
v1 = manager.save_version(rule_id: rule_id, rule_content: rule_content, changelog: "v1")
|
|
268
|
+
manager.save_version(rule_id: rule_id, rule_content: rule_content, changelog: "v2")
|
|
269
|
+
manager.save_version(rule_id: rule_id, rule_content: rule_content, changelog: "v3")
|
|
270
|
+
|
|
271
|
+
# Rollback to v1 should just activate it, not create a duplicate
|
|
272
|
+
rolled_back = manager.rollback(version_id: v1[:id], performed_by: "admin")
|
|
273
|
+
|
|
274
|
+
expect(rolled_back[:status]).to eq("active")
|
|
275
|
+
expect(rolled_back[:id]).to eq(v1[:id])
|
|
276
|
+
|
|
277
|
+
# Should NOT create a new version - just activate the old one
|
|
278
|
+
versions = manager.get_versions(rule_id: rule_id)
|
|
279
|
+
expect(versions.length).to eq(3) # Still just v1, v2, v3
|
|
280
|
+
|
|
281
|
+
# v1 should be active, v2 and v3 should be archived
|
|
282
|
+
active_version = manager.get_active_version(rule_id: rule_id)
|
|
283
|
+
expect(active_version[:id]).to eq(v1[:id])
|
|
284
|
+
expect(active_version[:version_number]).to eq(1)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
it "maintains version history integrity after rollback" do
|
|
288
|
+
v1 = manager.save_version(rule_id: rule_id, rule_content: rule_content.merge(data: "v1"), changelog: "Version 1")
|
|
289
|
+
v2 = manager.save_version(rule_id: rule_id, rule_content: rule_content.merge(data: "v2"), changelog: "Version 2")
|
|
290
|
+
v3 = manager.save_version(rule_id: rule_id, rule_content: rule_content.merge(data: "v3"), changelog: "Version 3")
|
|
291
|
+
|
|
292
|
+
# Rollback to v2
|
|
293
|
+
manager.rollback(version_id: v2[:id])
|
|
294
|
+
|
|
295
|
+
# All original versions should still exist with original data
|
|
296
|
+
loaded_v1 = manager.get_version(version_id: v1[:id])
|
|
297
|
+
loaded_v2 = manager.get_version(version_id: v2[:id])
|
|
298
|
+
loaded_v3 = manager.get_version(version_id: v3[:id])
|
|
299
|
+
|
|
300
|
+
expect(loaded_v1[:content][:data]).to eq("v1")
|
|
301
|
+
expect(loaded_v2[:content][:data]).to eq("v2")
|
|
302
|
+
expect(loaded_v3[:content][:data]).to eq("v3")
|
|
303
|
+
|
|
304
|
+
# v2 should be active
|
|
305
|
+
expect(loaded_v2[:status]).to eq("active")
|
|
306
|
+
expect(loaded_v1[:status]).to eq("archived")
|
|
307
|
+
expect(loaded_v3[:status]).to eq("archived")
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
describe "#get_history" do
|
|
312
|
+
it "returns comprehensive history with metadata" do
|
|
313
|
+
manager.save_version(rule_id: rule_id, rule_content: rule_content)
|
|
314
|
+
manager.save_version(rule_id: rule_id, rule_content: rule_content)
|
|
315
|
+
|
|
316
|
+
history = manager.get_history(rule_id: rule_id)
|
|
317
|
+
|
|
318
|
+
expect(history[:rule_id]).to eq(rule_id)
|
|
319
|
+
expect(history[:total_versions]).to eq(2)
|
|
320
|
+
expect(history[:active_version]).not_to be_nil
|
|
321
|
+
expect(history[:versions]).to be_an(Array)
|
|
322
|
+
expect(history[:created_at]).not_to be_nil
|
|
323
|
+
expect(history[:updated_at]).not_to be_nil
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
describe "edge cases and error handling" do
|
|
328
|
+
it "handles empty rule_id gracefully" do
|
|
329
|
+
expect do
|
|
330
|
+
manager.save_version(rule_id: "", rule_content: rule_content)
|
|
331
|
+
end.not_to raise_error
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
it "handles special characters in rule_id" do
|
|
335
|
+
special_rule_id = "rule-with_special.chars@123"
|
|
336
|
+
version = manager.save_version(rule_id: special_rule_id, rule_content: rule_content)
|
|
337
|
+
|
|
338
|
+
expect(version[:rule_id]).to eq(special_rule_id)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
it "handles large rule content" do
|
|
342
|
+
large_content = {
|
|
343
|
+
version: "1.0",
|
|
344
|
+
ruleset: "large_ruleset",
|
|
345
|
+
rules: Array.new(1000) do |i|
|
|
346
|
+
{
|
|
347
|
+
id: "rule_#{i}",
|
|
348
|
+
if: { field: "value", op: "eq", value: i },
|
|
349
|
+
then: { decision: "approve", weight: 0.5, reason: "Rule #{i}" }
|
|
350
|
+
}
|
|
351
|
+
end
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
version = manager.save_version(rule_id: rule_id, rule_content: large_content)
|
|
355
|
+
expect(version[:content][:rules].length).to eq(1000)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
it "handles deeply nested rule structures" do
|
|
359
|
+
nested_content = {
|
|
360
|
+
version: "1.0",
|
|
361
|
+
ruleset: "nested",
|
|
362
|
+
rules: [
|
|
363
|
+
{
|
|
364
|
+
id: "nested_rule",
|
|
365
|
+
if: {
|
|
366
|
+
all: [
|
|
367
|
+
{
|
|
368
|
+
any: [
|
|
369
|
+
{ field: "a", op: "eq", value: 1 },
|
|
370
|
+
{ field: "b", op: "eq", value: 2 }
|
|
371
|
+
]
|
|
372
|
+
},
|
|
373
|
+
{
|
|
374
|
+
all: [
|
|
375
|
+
{ field: "c", op: "gt", value: 3 },
|
|
376
|
+
{ field: "d", op: "lt", value: 4 }
|
|
377
|
+
]
|
|
378
|
+
}
|
|
379
|
+
]
|
|
380
|
+
},
|
|
381
|
+
then: { decision: "approve", weight: 0.8, reason: "Complex rule" }
|
|
382
|
+
}
|
|
383
|
+
]
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
version = manager.save_version(rule_id: rule_id, rule_content: nested_content)
|
|
387
|
+
expect(version[:content][:rules].first[:if][:all]).to be_an(Array)
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
it "preserves exact content structure including symbols and strings" do
|
|
391
|
+
mixed_content = {
|
|
392
|
+
version: "1.0",
|
|
393
|
+
ruleset: "mixed",
|
|
394
|
+
rules: [
|
|
395
|
+
{
|
|
396
|
+
id: "test",
|
|
397
|
+
metadata: {
|
|
398
|
+
string_key: "value",
|
|
399
|
+
number_key: 123,
|
|
400
|
+
boolean_key: true,
|
|
401
|
+
null_key: nil,
|
|
402
|
+
array_key: [1, 2, 3]
|
|
403
|
+
},
|
|
404
|
+
if: { field: "test", op: "eq", value: "value" },
|
|
405
|
+
then: { decision: "approve", weight: 0.5, reason: "Test" }
|
|
406
|
+
}
|
|
407
|
+
]
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
version = manager.save_version(rule_id: rule_id, rule_content: mixed_content)
|
|
411
|
+
loaded = manager.get_version(version_id: version[:id])
|
|
412
|
+
|
|
413
|
+
expect(loaded[:content][:rules].first[:metadata]).to eq(mixed_content[:rules].first[:metadata])
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
describe "concurrent version creation" do
|
|
418
|
+
it "maintains version number sequence with concurrent saves" do
|
|
419
|
+
threads = 10.times.map do |i|
|
|
420
|
+
Thread.new do
|
|
421
|
+
manager.save_version(
|
|
422
|
+
rule_id: rule_id,
|
|
423
|
+
rule_content: rule_content.merge(version: i.to_s),
|
|
424
|
+
created_by: "thread_#{i}"
|
|
425
|
+
)
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
threads.each(&:join)
|
|
430
|
+
|
|
431
|
+
versions = manager.get_versions(rule_id: rule_id)
|
|
432
|
+
version_numbers = versions.map { |v| v[:version_number] }.sort
|
|
433
|
+
|
|
434
|
+
expect(version_numbers).to eq((1..10).to_a)
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
describe "version lifecycle" do
|
|
439
|
+
it "tracks complete version lifecycle from draft to archived" do
|
|
440
|
+
# Create as draft
|
|
441
|
+
v1 = adapter.create_version(
|
|
442
|
+
rule_id: rule_id,
|
|
443
|
+
content: rule_content,
|
|
444
|
+
metadata: { status: "draft" }
|
|
445
|
+
)
|
|
446
|
+
expect(v1[:status]).to eq("draft")
|
|
447
|
+
|
|
448
|
+
# Activate
|
|
449
|
+
adapter.activate_version(version_id: v1[:id])
|
|
450
|
+
v1_updated = adapter.get_version(version_id: v1[:id])
|
|
451
|
+
expect(v1_updated[:status]).to eq("active")
|
|
452
|
+
|
|
453
|
+
# Create new version (archives previous)
|
|
454
|
+
v2 = adapter.create_version(rule_id: rule_id, content: rule_content)
|
|
455
|
+
v1_archived = adapter.get_version(version_id: v1[:id])
|
|
456
|
+
expect(v1_archived[:status]).to eq("archived")
|
|
457
|
+
expect(v2[:status]).to eq("active")
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
describe "comparison edge cases" do
|
|
462
|
+
it "compares identical versions" do
|
|
463
|
+
v1 = manager.save_version(rule_id: rule_id, rule_content: rule_content)
|
|
464
|
+
v2 = manager.save_version(rule_id: rule_id, rule_content: rule_content)
|
|
465
|
+
|
|
466
|
+
comparison = manager.compare(version_id_1: v1[:id], version_id_2: v2[:id])
|
|
467
|
+
|
|
468
|
+
# Should have minimal differences (just metadata changes)
|
|
469
|
+
expect(comparison[:differences][:added]).to be_empty
|
|
470
|
+
expect(comparison[:differences][:removed]).to be_empty
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
it "detects all types of changes" do
|
|
474
|
+
content_v1 = {
|
|
475
|
+
version: "1.0",
|
|
476
|
+
ruleset: "test",
|
|
477
|
+
rules: [
|
|
478
|
+
{ id: "rule_1", if: { field: "a", op: "eq", value: 1 }, then: { decision: "approve", weight: 0.8, reason: "Test" } }
|
|
479
|
+
]
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
content_v2 = {
|
|
483
|
+
version: "2.0", # changed
|
|
484
|
+
ruleset: "test",
|
|
485
|
+
rules: [
|
|
486
|
+
{ id: "rule_1", if: { field: "a", op: "eq", value: 2 }, then: { decision: "reject", weight: 0.9, reason: "Updated" } }, # modified
|
|
487
|
+
{ id: "rule_2", if: { field: "b", op: "gt", value: 100 }, then: { decision: "approve", weight: 0.7, reason: "New" } } # added
|
|
488
|
+
],
|
|
489
|
+
new_field: "added" # added field
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
v1 = manager.save_version(rule_id: rule_id, rule_content: content_v1)
|
|
493
|
+
v2 = manager.save_version(rule_id: rule_id, rule_content: content_v2)
|
|
494
|
+
|
|
495
|
+
comparison = manager.compare(version_id_1: v1[:id], version_id_2: v2[:id])
|
|
496
|
+
|
|
497
|
+
expect(comparison[:differences][:added].length).to be > 0
|
|
498
|
+
expect(comparison[:differences][:changed]).to have_key(:version)
|
|
499
|
+
end
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
describe "rollback scenarios" do
|
|
503
|
+
it "activates previous version without creating duplicates" do
|
|
504
|
+
v1 = manager.save_version(rule_id: rule_id, rule_content: rule_content, changelog: "Version 1")
|
|
505
|
+
manager.save_version(rule_id: rule_id, rule_content: rule_content, changelog: "Version 2")
|
|
506
|
+
manager.save_version(rule_id: rule_id, rule_content: rule_content, changelog: "Version 3")
|
|
507
|
+
|
|
508
|
+
manager.rollback(version_id: v1[:id], performed_by: "admin")
|
|
509
|
+
|
|
510
|
+
history = manager.get_history(rule_id: rule_id)
|
|
511
|
+
expect(history[:total_versions]).to eq(3) # Still just v1, v2, v3 - no duplicate
|
|
512
|
+
|
|
513
|
+
# v1 should be the active version
|
|
514
|
+
expect(history[:active_version][:id]).to eq(v1[:id])
|
|
515
|
+
expect(history[:active_version][:changelog]).to eq("Version 1")
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
it "handles multiple consecutive rollbacks without duplication" do
|
|
519
|
+
v1 = manager.save_version(rule_id: rule_id, rule_content: rule_content)
|
|
520
|
+
v2 = manager.save_version(rule_id: rule_id, rule_content: rule_content)
|
|
521
|
+
v3 = manager.save_version(rule_id: rule_id, rule_content: rule_content)
|
|
522
|
+
|
|
523
|
+
# Rollback to v1
|
|
524
|
+
result1 = manager.rollback(version_id: v1[:id], performed_by: "user1")
|
|
525
|
+
expect(result1[:id]).to eq(v1[:id])
|
|
526
|
+
|
|
527
|
+
# Rollback to v2
|
|
528
|
+
result2 = manager.rollback(version_id: v2[:id], performed_by: "user2")
|
|
529
|
+
expect(result2[:id]).to eq(v2[:id])
|
|
530
|
+
|
|
531
|
+
# Rollback to v3
|
|
532
|
+
result3 = manager.rollback(version_id: v3[:id], performed_by: "user3")
|
|
533
|
+
expect(result3[:id]).to eq(v3[:id])
|
|
534
|
+
|
|
535
|
+
history = manager.get_history(rule_id: rule_id)
|
|
536
|
+
expect(history[:total_versions]).to eq(3) # Still just the original 3 versions
|
|
537
|
+
expect(history[:active_version][:id]).to eq(v3[:id])
|
|
538
|
+
end
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
describe "query and filtering" do
|
|
542
|
+
it "filters versions by limit correctly" do
|
|
543
|
+
20.times { |i| manager.save_version(rule_id: rule_id, rule_content: rule_content, changelog: "Version #{i + 1}") }
|
|
544
|
+
|
|
545
|
+
versions_5 = manager.get_versions(rule_id: rule_id, limit: 5)
|
|
546
|
+
versions_10 = manager.get_versions(rule_id: rule_id, limit: 10)
|
|
547
|
+
|
|
548
|
+
expect(versions_5.length).to eq(5)
|
|
549
|
+
expect(versions_10.length).to eq(10)
|
|
550
|
+
|
|
551
|
+
# Most recent versions should come first
|
|
552
|
+
expect(versions_5.first[:version_number]).to eq(20)
|
|
553
|
+
expect(versions_5.last[:version_number]).to eq(16)
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
it "handles versions across multiple rules" do
|
|
557
|
+
rule_ids = %w[rule_a rule_b rule_c]
|
|
558
|
+
|
|
559
|
+
rule_ids.each do |rid|
|
|
560
|
+
3.times { manager.save_version(rule_id: rid, rule_content: rule_content) }
|
|
561
|
+
|
|
562
|
+
versions = manager.get_versions(rule_id: rid)
|
|
563
|
+
expect(versions.length).to eq(3)
|
|
564
|
+
expect(versions.all? { |v| v[:rule_id] == rid }).to be true
|
|
565
|
+
end
|
|
566
|
+
end
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
describe "error recovery" do
|
|
570
|
+
it "maintains data integrity after failed save" do
|
|
571
|
+
# This test ensures that even if there's an error, previous versions remain intact
|
|
572
|
+
manager.save_version(rule_id: rule_id, rule_content: rule_content)
|
|
573
|
+
|
|
574
|
+
begin
|
|
575
|
+
manager.save_version(rule_id: rule_id, rule_content: nil) # This should fail
|
|
576
|
+
rescue DecisionAgent::ValidationError
|
|
577
|
+
# Expected error
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
# Previous version should still be accessible
|
|
581
|
+
versions = manager.get_versions(rule_id: rule_id)
|
|
582
|
+
expect(versions.length).to eq(1)
|
|
583
|
+
expect(versions.first[:content]).to eq(rule_content)
|
|
584
|
+
end
|
|
585
|
+
end
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
describe "Integration Tests" do
|
|
589
|
+
let(:temp_dir) { Dir.mktmpdir }
|
|
590
|
+
let(:adapter) { DecisionAgent::Versioning::FileStorageAdapter.new(storage_path: temp_dir) }
|
|
591
|
+
let(:manager) { DecisionAgent::Versioning::VersionManager.new(adapter: adapter) }
|
|
592
|
+
|
|
593
|
+
after do
|
|
594
|
+
FileUtils.rm_rf(temp_dir)
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
describe "real-world workflow" do
|
|
598
|
+
it "handles a complete version management workflow" do
|
|
599
|
+
# 1. Create initial rule
|
|
600
|
+
approval_rule = {
|
|
601
|
+
version: "1.0",
|
|
602
|
+
ruleset: "approval_workflow",
|
|
603
|
+
rules: [
|
|
604
|
+
{
|
|
605
|
+
id: "high_value",
|
|
606
|
+
if: { field: "amount", op: "gt", value: 1000 },
|
|
607
|
+
then: { decision: "approve", weight: 0.9, reason: "High value customer" }
|
|
608
|
+
}
|
|
609
|
+
]
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
v1 = manager.save_version(
|
|
613
|
+
rule_id: "approval_001",
|
|
614
|
+
rule_content: approval_rule,
|
|
615
|
+
created_by: "product_manager",
|
|
616
|
+
changelog: "Initial approval rules"
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
expect(v1[:version_number]).to eq(1)
|
|
620
|
+
|
|
621
|
+
# 2. Update threshold
|
|
622
|
+
approval_rule[:rules].first[:if][:value] = 5000
|
|
623
|
+
v2 = manager.save_version(
|
|
624
|
+
rule_id: "approval_001",
|
|
625
|
+
rule_content: approval_rule,
|
|
626
|
+
created_by: "compliance_officer",
|
|
627
|
+
changelog: "Increased threshold per compliance requirements"
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
expect(v2[:version_number]).to eq(2)
|
|
631
|
+
|
|
632
|
+
# 3. Add new rule
|
|
633
|
+
approval_rule[:rules] << {
|
|
634
|
+
id: "fraud_check",
|
|
635
|
+
if: { field: "fraud_score", op: "gt", value: 0.7 },
|
|
636
|
+
then: { decision: "reject", weight: 1.0, reason: "High fraud risk" }
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
v3 = manager.save_version(
|
|
640
|
+
rule_id: "approval_001",
|
|
641
|
+
rule_content: approval_rule,
|
|
642
|
+
created_by: "security_team",
|
|
643
|
+
changelog: "Added fraud detection rule"
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
expect(v3[:version_number]).to eq(3)
|
|
647
|
+
expect(v3[:content][:rules].length).to eq(2)
|
|
648
|
+
|
|
649
|
+
# 4. Compare versions
|
|
650
|
+
comparison = manager.compare(version_id_1: v1[:id], version_id_2: v3[:id])
|
|
651
|
+
expect(comparison[:version_1][:version_number]).to eq(1)
|
|
652
|
+
expect(comparison[:version_2][:version_number]).to eq(3)
|
|
653
|
+
|
|
654
|
+
# 5. Rollback due to issue
|
|
655
|
+
rolled_back = manager.rollback(
|
|
656
|
+
version_id: v2[:id],
|
|
657
|
+
performed_by: "incident_responder"
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
expect(rolled_back[:status]).to eq("active")
|
|
661
|
+
expect(rolled_back[:id]).to eq(v2[:id])
|
|
662
|
+
|
|
663
|
+
# 6. Verify history
|
|
664
|
+
history = manager.get_history(rule_id: "approval_001")
|
|
665
|
+
expect(history[:total_versions]).to eq(3) # v1, v2, v3 - no duplicate created
|
|
666
|
+
expect(history[:active_version][:version_number]).to eq(2) # v2 is active
|
|
667
|
+
end
|
|
668
|
+
end
|
|
669
|
+
|
|
670
|
+
describe "multi-rule management" do
|
|
671
|
+
it "manages versions for multiple related rules" do
|
|
672
|
+
rulesets = {
|
|
673
|
+
"approval" => {
|
|
674
|
+
version: "1.0",
|
|
675
|
+
ruleset: "approval",
|
|
676
|
+
rules: [{ id: "approve_1", if: { field: "amount", op: "lt", value: 100 }, then: { decision: "approve", weight: 0.8, reason: "Low amount" } }]
|
|
677
|
+
},
|
|
678
|
+
"rejection" => {
|
|
679
|
+
version: "1.0",
|
|
680
|
+
ruleset: "rejection",
|
|
681
|
+
rules: [{ id: "reject_1", if: { field: "risk_score", op: "gt", value: 0.9 }, then: { decision: "reject", weight: 1.0, reason: "High risk" } }]
|
|
682
|
+
},
|
|
683
|
+
"review" => {
|
|
684
|
+
version: "1.0",
|
|
685
|
+
ruleset: "review",
|
|
686
|
+
rules: [{ id: "review_1", if: { field: "amount", op: "gte", value: 10_000 }, then: { decision: "manual_review", weight: 0.9, reason: "Large transaction" } }]
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
# Create versions for all rulesets
|
|
691
|
+
rulesets.each do |name, content|
|
|
692
|
+
manager.save_version(
|
|
693
|
+
rule_id: name,
|
|
694
|
+
rule_content: content,
|
|
695
|
+
created_by: "system",
|
|
696
|
+
changelog: "Initial #{name} rules"
|
|
697
|
+
)
|
|
698
|
+
end
|
|
699
|
+
|
|
700
|
+
# Verify each has its own version history
|
|
701
|
+
rulesets.each_key do |name|
|
|
702
|
+
history = manager.get_history(rule_id: name)
|
|
703
|
+
expect(history[:total_versions]).to eq(1)
|
|
704
|
+
expect(history[:active_version][:rule_id]).to eq(name)
|
|
705
|
+
end
|
|
706
|
+
end
|
|
707
|
+
end
|
|
708
|
+
|
|
709
|
+
describe "status validation" do
|
|
710
|
+
let(:rule_id) { "test_status_rule" }
|
|
711
|
+
let(:rule_content) do
|
|
712
|
+
{
|
|
713
|
+
version: "1.0",
|
|
714
|
+
ruleset: "test",
|
|
715
|
+
rules: [{ id: "test", if: { field: "x", op: "eq", value: 1 }, then: { decision: "approve", weight: 0.8, reason: "Test" } }]
|
|
716
|
+
}
|
|
717
|
+
end
|
|
718
|
+
|
|
719
|
+
it "rejects invalid status values when creating versions" do
|
|
720
|
+
expect do
|
|
721
|
+
adapter.create_version(
|
|
722
|
+
rule_id: rule_id,
|
|
723
|
+
content: rule_content,
|
|
724
|
+
metadata: { status: "banana" }
|
|
725
|
+
)
|
|
726
|
+
end.to raise_error(DecisionAgent::ValidationError, /Invalid status 'banana'/)
|
|
727
|
+
|
|
728
|
+
expect do
|
|
729
|
+
adapter.create_version(
|
|
730
|
+
rule_id: rule_id,
|
|
731
|
+
content: rule_content,
|
|
732
|
+
metadata: { status: "pending" }
|
|
733
|
+
)
|
|
734
|
+
end.to raise_error(DecisionAgent::ValidationError, /Invalid status 'pending'/)
|
|
735
|
+
|
|
736
|
+
expect do
|
|
737
|
+
adapter.create_version(
|
|
738
|
+
rule_id: rule_id,
|
|
739
|
+
content: rule_content,
|
|
740
|
+
metadata: { status: "deleted" }
|
|
741
|
+
)
|
|
742
|
+
end.to raise_error(DecisionAgent::ValidationError, /Invalid status 'deleted'/)
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
it "accepts valid status values" do
|
|
746
|
+
v1 = adapter.create_version(
|
|
747
|
+
rule_id: rule_id,
|
|
748
|
+
content: rule_content,
|
|
749
|
+
metadata: { status: "draft" }
|
|
750
|
+
)
|
|
751
|
+
expect(v1[:status]).to eq("draft")
|
|
752
|
+
|
|
753
|
+
v2 = adapter.create_version(
|
|
754
|
+
rule_id: "rule_002",
|
|
755
|
+
content: rule_content,
|
|
756
|
+
metadata: { status: "active" }
|
|
757
|
+
)
|
|
758
|
+
expect(v2[:status]).to eq("active")
|
|
759
|
+
|
|
760
|
+
v3 = adapter.create_version(
|
|
761
|
+
rule_id: "rule_003",
|
|
762
|
+
content: rule_content,
|
|
763
|
+
metadata: { status: "archived" }
|
|
764
|
+
)
|
|
765
|
+
expect(v3[:status]).to eq("archived")
|
|
766
|
+
end
|
|
767
|
+
|
|
768
|
+
it "uses default status 'active' when not provided" do
|
|
769
|
+
version = adapter.create_version(
|
|
770
|
+
rule_id: rule_id,
|
|
771
|
+
content: rule_content
|
|
772
|
+
)
|
|
773
|
+
expect(version[:status]).to eq("active")
|
|
774
|
+
end
|
|
775
|
+
end
|
|
776
|
+
end
|
|
777
|
+
end
|