decision_agent 0.1.1 → 0.1.2
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 +138 -1000
- data/bin/decision_agent +5 -0
- data/lib/decision_agent/errors.rb +12 -0
- data/lib/decision_agent/version.rb +1 -1
- data/lib/decision_agent/versioning/activerecord_adapter.rb +105 -0
- data/lib/decision_agent/versioning/adapter.rb +102 -0
- data/lib/decision_agent/versioning/file_storage_adapter.rb +182 -0
- data/lib/decision_agent/versioning/version_manager.rb +135 -0
- data/lib/decision_agent/web/public/app.js +318 -0
- data/lib/decision_agent/web/public/index.html +55 -0
- data/lib/decision_agent/web/public/styles.css +219 -0
- data/lib/decision_agent/web/server.rb +166 -1
- data/lib/decision_agent.rb +4 -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 +26 -0
- data/lib/generators/decision_agent/install/templates/rule.rb +30 -0
- data/lib/generators/decision_agent/install/templates/rule_version.rb +60 -0
- data/spec/versioning_spec.rb +673 -0
- metadata +17 -7
|
@@ -0,0 +1,673 @@
|
|
|
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 {
|
|
160
|
+
adapter.activate_version(version_id: "nonexistent")
|
|
161
|
+
}.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 {
|
|
231
|
+
manager.save_version(rule_id: rule_id, rule_content: nil)
|
|
232
|
+
}.to raise_error(DecisionAgent::ValidationError, /cannot be nil/)
|
|
233
|
+
|
|
234
|
+
expect {
|
|
235
|
+
manager.save_version(rule_id: rule_id, rule_content: "not a hash")
|
|
236
|
+
}.to raise_error(DecisionAgent::ValidationError, /must be a Hash/)
|
|
237
|
+
|
|
238
|
+
expect {
|
|
239
|
+
manager.save_version(rule_id: rule_id, rule_content: {})
|
|
240
|
+
}.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 and creates new version" 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
|
+
|
|
270
|
+
rolled_back = manager.rollback(version_id: v1[:id], performed_by: "admin")
|
|
271
|
+
|
|
272
|
+
expect(rolled_back[:status]).to eq("active")
|
|
273
|
+
|
|
274
|
+
# Should create a new version documenting the rollback
|
|
275
|
+
versions = manager.get_versions(rule_id: rule_id)
|
|
276
|
+
expect(versions.length).to eq(3) # v1, v2, and rollback version
|
|
277
|
+
expect(versions.first[:changelog]).to include("Rolled back")
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
describe "#get_history" do
|
|
282
|
+
it "returns comprehensive history with metadata" do
|
|
283
|
+
manager.save_version(rule_id: rule_id, rule_content: rule_content)
|
|
284
|
+
manager.save_version(rule_id: rule_id, rule_content: rule_content)
|
|
285
|
+
|
|
286
|
+
history = manager.get_history(rule_id: rule_id)
|
|
287
|
+
|
|
288
|
+
expect(history[:rule_id]).to eq(rule_id)
|
|
289
|
+
expect(history[:total_versions]).to eq(2)
|
|
290
|
+
expect(history[:active_version]).not_to be_nil
|
|
291
|
+
expect(history[:versions]).to be_an(Array)
|
|
292
|
+
expect(history[:created_at]).not_to be_nil
|
|
293
|
+
expect(history[:updated_at]).not_to be_nil
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
describe "edge cases and error handling" do
|
|
298
|
+
it "handles empty rule_id gracefully" do
|
|
299
|
+
expect {
|
|
300
|
+
manager.save_version(rule_id: "", rule_content: rule_content)
|
|
301
|
+
}.not_to raise_error
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
it "handles special characters in rule_id" do
|
|
305
|
+
special_rule_id = "rule-with_special.chars@123"
|
|
306
|
+
version = manager.save_version(rule_id: special_rule_id, rule_content: rule_content)
|
|
307
|
+
|
|
308
|
+
expect(version[:rule_id]).to eq(special_rule_id)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
it "handles large rule content" do
|
|
312
|
+
large_content = {
|
|
313
|
+
version: "1.0",
|
|
314
|
+
ruleset: "large_ruleset",
|
|
315
|
+
rules: Array.new(1000) do |i|
|
|
316
|
+
{
|
|
317
|
+
id: "rule_#{i}",
|
|
318
|
+
if: { field: "value", op: "eq", value: i },
|
|
319
|
+
then: { decision: "approve", weight: 0.5, reason: "Rule #{i}" }
|
|
320
|
+
}
|
|
321
|
+
end
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
version = manager.save_version(rule_id: rule_id, rule_content: large_content)
|
|
325
|
+
expect(version[:content][:rules].length).to eq(1000)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
it "handles deeply nested rule structures" do
|
|
329
|
+
nested_content = {
|
|
330
|
+
version: "1.0",
|
|
331
|
+
ruleset: "nested",
|
|
332
|
+
rules: [
|
|
333
|
+
{
|
|
334
|
+
id: "nested_rule",
|
|
335
|
+
if: {
|
|
336
|
+
all: [
|
|
337
|
+
{
|
|
338
|
+
any: [
|
|
339
|
+
{ field: "a", op: "eq", value: 1 },
|
|
340
|
+
{ field: "b", op: "eq", value: 2 }
|
|
341
|
+
]
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
all: [
|
|
345
|
+
{ field: "c", op: "gt", value: 3 },
|
|
346
|
+
{ field: "d", op: "lt", value: 4 }
|
|
347
|
+
]
|
|
348
|
+
}
|
|
349
|
+
]
|
|
350
|
+
},
|
|
351
|
+
then: { decision: "approve", weight: 0.8, reason: "Complex rule" }
|
|
352
|
+
}
|
|
353
|
+
]
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
version = manager.save_version(rule_id: rule_id, rule_content: nested_content)
|
|
357
|
+
expect(version[:content][:rules].first[:if][:all]).to be_an(Array)
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
it "preserves exact content structure including symbols and strings" do
|
|
361
|
+
mixed_content = {
|
|
362
|
+
version: "1.0",
|
|
363
|
+
ruleset: "mixed",
|
|
364
|
+
rules: [
|
|
365
|
+
{
|
|
366
|
+
id: "test",
|
|
367
|
+
metadata: {
|
|
368
|
+
string_key: "value",
|
|
369
|
+
number_key: 123,
|
|
370
|
+
boolean_key: true,
|
|
371
|
+
null_key: nil,
|
|
372
|
+
array_key: [1, 2, 3]
|
|
373
|
+
},
|
|
374
|
+
if: { field: "test", op: "eq", value: "value" },
|
|
375
|
+
then: { decision: "approve", weight: 0.5, reason: "Test" }
|
|
376
|
+
}
|
|
377
|
+
]
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
version = manager.save_version(rule_id: rule_id, rule_content: mixed_content)
|
|
381
|
+
loaded = manager.get_version(version_id: version[:id])
|
|
382
|
+
|
|
383
|
+
expect(loaded[:content][:rules].first[:metadata]).to eq(mixed_content[:rules].first[:metadata])
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
describe "concurrent version creation" do
|
|
388
|
+
it "maintains version number sequence with concurrent saves" do
|
|
389
|
+
threads = 10.times.map do |i|
|
|
390
|
+
Thread.new do
|
|
391
|
+
manager.save_version(
|
|
392
|
+
rule_id: rule_id,
|
|
393
|
+
rule_content: rule_content.merge(version: "#{i}"),
|
|
394
|
+
created_by: "thread_#{i}"
|
|
395
|
+
)
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
threads.each(&:join)
|
|
400
|
+
|
|
401
|
+
versions = manager.get_versions(rule_id: rule_id)
|
|
402
|
+
version_numbers = versions.map { |v| v[:version_number] }.sort
|
|
403
|
+
|
|
404
|
+
expect(version_numbers).to eq((1..10).to_a)
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
describe "version lifecycle" do
|
|
409
|
+
it "tracks complete version lifecycle from draft to archived" do
|
|
410
|
+
# Create as draft
|
|
411
|
+
v1 = adapter.create_version(
|
|
412
|
+
rule_id: rule_id,
|
|
413
|
+
content: rule_content,
|
|
414
|
+
metadata: { status: "draft" }
|
|
415
|
+
)
|
|
416
|
+
expect(v1[:status]).to eq("draft")
|
|
417
|
+
|
|
418
|
+
# Activate
|
|
419
|
+
adapter.activate_version(version_id: v1[:id])
|
|
420
|
+
v1_updated = adapter.get_version(version_id: v1[:id])
|
|
421
|
+
expect(v1_updated[:status]).to eq("active")
|
|
422
|
+
|
|
423
|
+
# Create new version (archives previous)
|
|
424
|
+
v2 = adapter.create_version(rule_id: rule_id, content: rule_content)
|
|
425
|
+
v1_archived = adapter.get_version(version_id: v1[:id])
|
|
426
|
+
expect(v1_archived[:status]).to eq("archived")
|
|
427
|
+
expect(v2[:status]).to eq("active")
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
describe "comparison edge cases" do
|
|
432
|
+
it "compares identical versions" do
|
|
433
|
+
v1 = manager.save_version(rule_id: rule_id, rule_content: rule_content)
|
|
434
|
+
v2 = manager.save_version(rule_id: rule_id, rule_content: rule_content)
|
|
435
|
+
|
|
436
|
+
comparison = manager.compare(version_id_1: v1[:id], version_id_2: v2[:id])
|
|
437
|
+
|
|
438
|
+
# Should have minimal differences (just metadata changes)
|
|
439
|
+
expect(comparison[:differences][:added]).to be_empty
|
|
440
|
+
expect(comparison[:differences][:removed]).to be_empty
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
it "detects all types of changes" do
|
|
444
|
+
content_v1 = {
|
|
445
|
+
version: "1.0",
|
|
446
|
+
ruleset: "test",
|
|
447
|
+
rules: [
|
|
448
|
+
{ id: "rule_1", if: { field: "a", op: "eq", value: 1 }, then: { decision: "approve", weight: 0.8, reason: "Test" } }
|
|
449
|
+
]
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
content_v2 = {
|
|
453
|
+
version: "2.0", # changed
|
|
454
|
+
ruleset: "test",
|
|
455
|
+
rules: [
|
|
456
|
+
{ id: "rule_1", if: { field: "a", op: "eq", value: 2 }, then: { decision: "reject", weight: 0.9, reason: "Updated" } }, # modified
|
|
457
|
+
{ id: "rule_2", if: { field: "b", op: "gt", value: 100 }, then: { decision: "approve", weight: 0.7, reason: "New" } } # added
|
|
458
|
+
],
|
|
459
|
+
new_field: "added" # added field
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
v1 = manager.save_version(rule_id: rule_id, rule_content: content_v1)
|
|
463
|
+
v2 = manager.save_version(rule_id: rule_id, rule_content: content_v2)
|
|
464
|
+
|
|
465
|
+
comparison = manager.compare(version_id_1: v1[:id], version_id_2: v2[:id])
|
|
466
|
+
|
|
467
|
+
expect(comparison[:differences][:added].length).to be > 0
|
|
468
|
+
expect(comparison[:differences][:changed]).to have_key(:version)
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
describe "rollback scenarios" do
|
|
473
|
+
it "creates proper audit trail on rollback" do
|
|
474
|
+
v1 = manager.save_version(rule_id: rule_id, rule_content: rule_content, changelog: "Version 1")
|
|
475
|
+
manager.save_version(rule_id: rule_id, rule_content: rule_content, changelog: "Version 2")
|
|
476
|
+
manager.save_version(rule_id: rule_id, rule_content: rule_content, changelog: "Version 3")
|
|
477
|
+
|
|
478
|
+
manager.rollback(version_id: v1[:id], performed_by: "admin")
|
|
479
|
+
|
|
480
|
+
history = manager.get_history(rule_id: rule_id)
|
|
481
|
+
expect(history[:total_versions]).to eq(4) # v1, v2, v3, rollback version
|
|
482
|
+
|
|
483
|
+
rollback_version = history[:versions].first
|
|
484
|
+
expect(rollback_version[:changelog]).to include("Rolled back")
|
|
485
|
+
expect(rollback_version[:changelog]).to include("version 1")
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
it "handles multiple consecutive rollbacks" do
|
|
489
|
+
v1 = manager.save_version(rule_id: rule_id, rule_content: rule_content)
|
|
490
|
+
v2 = manager.save_version(rule_id: rule_id, rule_content: rule_content)
|
|
491
|
+
manager.save_version(rule_id: rule_id, rule_content: rule_content)
|
|
492
|
+
|
|
493
|
+
# Rollback to v1
|
|
494
|
+
manager.rollback(version_id: v1[:id], performed_by: "user1")
|
|
495
|
+
|
|
496
|
+
# Rollback to v2
|
|
497
|
+
manager.rollback(version_id: v2[:id], performed_by: "user2")
|
|
498
|
+
|
|
499
|
+
history = manager.get_history(rule_id: rule_id)
|
|
500
|
+
expect(history[:total_versions]).to eq(5) # Original 3 + 2 rollback versions
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
describe "query and filtering" do
|
|
505
|
+
it "filters versions by limit correctly" do
|
|
506
|
+
20.times { |i| manager.save_version(rule_id: rule_id, rule_content: rule_content, changelog: "Version #{i + 1}") }
|
|
507
|
+
|
|
508
|
+
versions_5 = manager.get_versions(rule_id: rule_id, limit: 5)
|
|
509
|
+
versions_10 = manager.get_versions(rule_id: rule_id, limit: 10)
|
|
510
|
+
|
|
511
|
+
expect(versions_5.length).to eq(5)
|
|
512
|
+
expect(versions_10.length).to eq(10)
|
|
513
|
+
|
|
514
|
+
# Most recent versions should come first
|
|
515
|
+
expect(versions_5.first[:version_number]).to eq(20)
|
|
516
|
+
expect(versions_5.last[:version_number]).to eq(16)
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
it "handles versions across multiple rules" do
|
|
520
|
+
rule_ids = ["rule_a", "rule_b", "rule_c"]
|
|
521
|
+
|
|
522
|
+
rule_ids.each do |rid|
|
|
523
|
+
3.times { manager.save_version(rule_id: rid, rule_content: rule_content) }
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
rule_ids.each do |rid|
|
|
527
|
+
versions = manager.get_versions(rule_id: rid)
|
|
528
|
+
expect(versions.length).to eq(3)
|
|
529
|
+
expect(versions.all? { |v| v[:rule_id] == rid }).to be true
|
|
530
|
+
end
|
|
531
|
+
end
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
describe "error recovery" do
|
|
535
|
+
it "maintains data integrity after failed save" do
|
|
536
|
+
# This test ensures that even if there's an error, previous versions remain intact
|
|
537
|
+
manager.save_version(rule_id: rule_id, rule_content: rule_content)
|
|
538
|
+
|
|
539
|
+
begin
|
|
540
|
+
manager.save_version(rule_id: rule_id, rule_content: nil) # This should fail
|
|
541
|
+
rescue DecisionAgent::ValidationError
|
|
542
|
+
# Expected error
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
# Previous version should still be accessible
|
|
546
|
+
versions = manager.get_versions(rule_id: rule_id)
|
|
547
|
+
expect(versions.length).to eq(1)
|
|
548
|
+
expect(versions.first[:content]).to eq(rule_content)
|
|
549
|
+
end
|
|
550
|
+
end
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
describe "Integration Tests" do
|
|
554
|
+
let(:temp_dir) { Dir.mktmpdir }
|
|
555
|
+
let(:adapter) { DecisionAgent::Versioning::FileStorageAdapter.new(storage_path: temp_dir) }
|
|
556
|
+
let(:manager) { DecisionAgent::Versioning::VersionManager.new(adapter: adapter) }
|
|
557
|
+
|
|
558
|
+
after do
|
|
559
|
+
FileUtils.rm_rf(temp_dir)
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
describe "real-world workflow" do
|
|
563
|
+
it "handles a complete version management workflow" do
|
|
564
|
+
# 1. Create initial rule
|
|
565
|
+
approval_rule = {
|
|
566
|
+
version: "1.0",
|
|
567
|
+
ruleset: "approval_workflow",
|
|
568
|
+
rules: [
|
|
569
|
+
{
|
|
570
|
+
id: "high_value",
|
|
571
|
+
if: { field: "amount", op: "gt", value: 1000 },
|
|
572
|
+
then: { decision: "approve", weight: 0.9, reason: "High value customer" }
|
|
573
|
+
}
|
|
574
|
+
]
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
v1 = manager.save_version(
|
|
578
|
+
rule_id: "approval_001",
|
|
579
|
+
rule_content: approval_rule,
|
|
580
|
+
created_by: "product_manager",
|
|
581
|
+
changelog: "Initial approval rules"
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
expect(v1[:version_number]).to eq(1)
|
|
585
|
+
|
|
586
|
+
# 2. Update threshold
|
|
587
|
+
approval_rule[:rules].first[:if][:value] = 5000
|
|
588
|
+
v2 = manager.save_version(
|
|
589
|
+
rule_id: "approval_001",
|
|
590
|
+
rule_content: approval_rule,
|
|
591
|
+
created_by: "compliance_officer",
|
|
592
|
+
changelog: "Increased threshold per compliance requirements"
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
expect(v2[:version_number]).to eq(2)
|
|
596
|
+
|
|
597
|
+
# 3. Add new rule
|
|
598
|
+
approval_rule[:rules] << {
|
|
599
|
+
id: "fraud_check",
|
|
600
|
+
if: { field: "fraud_score", op: "gt", value: 0.7 },
|
|
601
|
+
then: { decision: "reject", weight: 1.0, reason: "High fraud risk" }
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
v3 = manager.save_version(
|
|
605
|
+
rule_id: "approval_001",
|
|
606
|
+
rule_content: approval_rule,
|
|
607
|
+
created_by: "security_team",
|
|
608
|
+
changelog: "Added fraud detection rule"
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
expect(v3[:version_number]).to eq(3)
|
|
612
|
+
expect(v3[:content][:rules].length).to eq(2)
|
|
613
|
+
|
|
614
|
+
# 4. Compare versions
|
|
615
|
+
comparison = manager.compare(version_id_1: v1[:id], version_id_2: v3[:id])
|
|
616
|
+
expect(comparison[:version_1][:version_number]).to eq(1)
|
|
617
|
+
expect(comparison[:version_2][:version_number]).to eq(3)
|
|
618
|
+
|
|
619
|
+
# 5. Rollback due to issue
|
|
620
|
+
rolled_back = manager.rollback(
|
|
621
|
+
version_id: v2[:id],
|
|
622
|
+
performed_by: "incident_responder"
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
expect(rolled_back[:status]).to eq("active")
|
|
626
|
+
|
|
627
|
+
# 6. Verify history
|
|
628
|
+
history = manager.get_history(rule_id: "approval_001")
|
|
629
|
+
expect(history[:total_versions]).to eq(4) # v1, v2, v3, rollback
|
|
630
|
+
expect(history[:active_version][:version_number]).to be > 3
|
|
631
|
+
end
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
describe "multi-rule management" do
|
|
635
|
+
it "manages versions for multiple related rules" do
|
|
636
|
+
rulesets = {
|
|
637
|
+
"approval" => {
|
|
638
|
+
version: "1.0",
|
|
639
|
+
ruleset: "approval",
|
|
640
|
+
rules: [{ id: "approve_1", if: { field: "amount", op: "lt", value: 100 }, then: { decision: "approve", weight: 0.8, reason: "Low amount" } }]
|
|
641
|
+
},
|
|
642
|
+
"rejection" => {
|
|
643
|
+
version: "1.0",
|
|
644
|
+
ruleset: "rejection",
|
|
645
|
+
rules: [{ id: "reject_1", if: { field: "risk_score", op: "gt", value: 0.9 }, then: { decision: "reject", weight: 1.0, reason: "High risk" } }]
|
|
646
|
+
},
|
|
647
|
+
"review" => {
|
|
648
|
+
version: "1.0",
|
|
649
|
+
ruleset: "review",
|
|
650
|
+
rules: [{ id: "review_1", if: { field: "amount", op: "gte", value: 10000 }, then: { decision: "manual_review", weight: 0.9, reason: "Large transaction" } }]
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
# Create versions for all rulesets
|
|
655
|
+
rulesets.each do |name, content|
|
|
656
|
+
manager.save_version(
|
|
657
|
+
rule_id: name,
|
|
658
|
+
rule_content: content,
|
|
659
|
+
created_by: "system",
|
|
660
|
+
changelog: "Initial #{name} rules"
|
|
661
|
+
)
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
# Verify each has its own version history
|
|
665
|
+
rulesets.keys.each do |name|
|
|
666
|
+
history = manager.get_history(rule_id: name)
|
|
667
|
+
expect(history[:total_versions]).to eq(1)
|
|
668
|
+
expect(history[:active_version][:rule_id]).to eq(name)
|
|
669
|
+
end
|
|
670
|
+
end
|
|
671
|
+
end
|
|
672
|
+
end
|
|
673
|
+
end
|