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.
@@ -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