decision_agent 0.3.0 → 1.0.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.
Files changed (115) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +272 -7
  3. data/lib/decision_agent/agent.rb +72 -1
  4. data/lib/decision_agent/context.rb +1 -0
  5. data/lib/decision_agent/data_enrichment/cache/memory_adapter.rb +86 -0
  6. data/lib/decision_agent/data_enrichment/cache_adapter.rb +49 -0
  7. data/lib/decision_agent/data_enrichment/circuit_breaker.rb +135 -0
  8. data/lib/decision_agent/data_enrichment/client.rb +220 -0
  9. data/lib/decision_agent/data_enrichment/config.rb +78 -0
  10. data/lib/decision_agent/data_enrichment/errors.rb +36 -0
  11. data/lib/decision_agent/decision.rb +102 -2
  12. data/lib/decision_agent/dmn/feel/evaluator.rb +28 -6
  13. data/lib/decision_agent/dsl/condition_evaluator.rb +982 -839
  14. data/lib/decision_agent/dsl/schema_validator.rb +51 -13
  15. data/lib/decision_agent/evaluators/dmn_evaluator.rb +106 -19
  16. data/lib/decision_agent/evaluators/json_rule_evaluator.rb +69 -9
  17. data/lib/decision_agent/explainability/condition_trace.rb +83 -0
  18. data/lib/decision_agent/explainability/explainability_result.rb +52 -0
  19. data/lib/decision_agent/explainability/rule_trace.rb +39 -0
  20. data/lib/decision_agent/explainability/trace_collector.rb +24 -0
  21. data/lib/decision_agent/monitoring/alert_manager.rb +5 -1
  22. data/lib/decision_agent/simulation/errors.rb +18 -0
  23. data/lib/decision_agent/simulation/impact_analyzer.rb +498 -0
  24. data/lib/decision_agent/simulation/monte_carlo_simulator.rb +635 -0
  25. data/lib/decision_agent/simulation/replay_engine.rb +486 -0
  26. data/lib/decision_agent/simulation/scenario_engine.rb +318 -0
  27. data/lib/decision_agent/simulation/scenario_library.rb +163 -0
  28. data/lib/decision_agent/simulation/shadow_test_engine.rb +287 -0
  29. data/lib/decision_agent/simulation/what_if_analyzer.rb +1002 -0
  30. data/lib/decision_agent/simulation.rb +17 -0
  31. data/lib/decision_agent/version.rb +1 -1
  32. data/lib/decision_agent/versioning/activerecord_adapter.rb +23 -8
  33. data/lib/decision_agent/web/public/app.js +119 -0
  34. data/lib/decision_agent/web/public/index.html +49 -0
  35. data/lib/decision_agent/web/public/simulation.html +130 -0
  36. data/lib/decision_agent/web/public/simulation_impact.html +478 -0
  37. data/lib/decision_agent/web/public/simulation_replay.html +551 -0
  38. data/lib/decision_agent/web/public/simulation_shadow.html +546 -0
  39. data/lib/decision_agent/web/public/simulation_whatif.html +532 -0
  40. data/lib/decision_agent/web/public/styles.css +65 -0
  41. data/lib/decision_agent/web/server.rb +594 -23
  42. data/lib/decision_agent.rb +60 -2
  43. metadata +53 -73
  44. data/spec/ab_testing/ab_test_assignment_spec.rb +0 -253
  45. data/spec/ab_testing/ab_test_manager_spec.rb +0 -612
  46. data/spec/ab_testing/ab_test_spec.rb +0 -270
  47. data/spec/ab_testing/ab_testing_agent_spec.rb +0 -655
  48. data/spec/ab_testing/storage/adapter_spec.rb +0 -64
  49. data/spec/ab_testing/storage/memory_adapter_spec.rb +0 -485
  50. data/spec/activerecord_thread_safety_spec.rb +0 -553
  51. data/spec/advanced_operators_spec.rb +0 -3150
  52. data/spec/agent_spec.rb +0 -289
  53. data/spec/api_contract_spec.rb +0 -430
  54. data/spec/audit_adapters_spec.rb +0 -92
  55. data/spec/auth/access_audit_logger_spec.rb +0 -394
  56. data/spec/auth/authenticator_spec.rb +0 -112
  57. data/spec/auth/password_reset_spec.rb +0 -294
  58. data/spec/auth/permission_checker_spec.rb +0 -207
  59. data/spec/auth/permission_spec.rb +0 -73
  60. data/spec/auth/rbac_adapter_spec.rb +0 -778
  61. data/spec/auth/rbac_config_spec.rb +0 -82
  62. data/spec/auth/role_spec.rb +0 -51
  63. data/spec/auth/session_manager_spec.rb +0 -172
  64. data/spec/auth/session_spec.rb +0 -112
  65. data/spec/auth/user_spec.rb +0 -130
  66. data/spec/comprehensive_edge_cases_spec.rb +0 -1777
  67. data/spec/context_spec.rb +0 -127
  68. data/spec/decision_agent_spec.rb +0 -96
  69. data/spec/decision_spec.rb +0 -423
  70. data/spec/dmn/decision_graph_spec.rb +0 -282
  71. data/spec/dmn/decision_tree_spec.rb +0 -203
  72. data/spec/dmn/feel/errors_spec.rb +0 -18
  73. data/spec/dmn/feel/functions_spec.rb +0 -400
  74. data/spec/dmn/feel/simple_parser_spec.rb +0 -274
  75. data/spec/dmn/feel/types_spec.rb +0 -176
  76. data/spec/dmn/feel_parser_spec.rb +0 -489
  77. data/spec/dmn/hit_policy_spec.rb +0 -202
  78. data/spec/dmn/integration_spec.rb +0 -226
  79. data/spec/dsl/condition_evaluator_spec.rb +0 -774
  80. data/spec/dsl_validation_spec.rb +0 -648
  81. data/spec/edge_cases_spec.rb +0 -353
  82. data/spec/evaluation_spec.rb +0 -364
  83. data/spec/evaluation_validator_spec.rb +0 -165
  84. data/spec/examples/feedback_aware_evaluator_spec.rb +0 -460
  85. data/spec/examples.txt +0 -1909
  86. data/spec/fixtures/dmn/complex_decision.dmn +0 -81
  87. data/spec/fixtures/dmn/invalid_structure.dmn +0 -31
  88. data/spec/fixtures/dmn/simple_decision.dmn +0 -40
  89. data/spec/issue_verification_spec.rb +0 -759
  90. data/spec/json_rule_evaluator_spec.rb +0 -587
  91. data/spec/monitoring/alert_manager_spec.rb +0 -378
  92. data/spec/monitoring/metrics_collector_spec.rb +0 -501
  93. data/spec/monitoring/monitored_agent_spec.rb +0 -225
  94. data/spec/monitoring/prometheus_exporter_spec.rb +0 -242
  95. data/spec/monitoring/storage/activerecord_adapter_spec.rb +0 -498
  96. data/spec/monitoring/storage/base_adapter_spec.rb +0 -61
  97. data/spec/monitoring/storage/memory_adapter_spec.rb +0 -247
  98. data/spec/performance_optimizations_spec.rb +0 -493
  99. data/spec/replay_edge_cases_spec.rb +0 -699
  100. data/spec/replay_spec.rb +0 -210
  101. data/spec/rfc8785_canonicalization_spec.rb +0 -215
  102. data/spec/scoring_spec.rb +0 -225
  103. data/spec/spec_helper.rb +0 -60
  104. data/spec/testing/batch_test_importer_spec.rb +0 -693
  105. data/spec/testing/batch_test_runner_spec.rb +0 -307
  106. data/spec/testing/test_coverage_analyzer_spec.rb +0 -292
  107. data/spec/testing/test_result_comparator_spec.rb +0 -392
  108. data/spec/testing/test_scenario_spec.rb +0 -113
  109. data/spec/thread_safety_spec.rb +0 -490
  110. data/spec/thread_safety_spec.rb.broken +0 -878
  111. data/spec/versioning/adapter_spec.rb +0 -156
  112. data/spec/versioning_spec.rb +0 -1030
  113. data/spec/web/middleware/auth_middleware_spec.rb +0 -133
  114. data/spec/web/middleware/permission_middleware_spec.rb +0 -247
  115. data/spec/web_ui_rack_spec.rb +0 -2134
@@ -1,1030 +0,0 @@
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
-
190
- describe "#delete_version" do
191
- it "deletes a version and removes it from index" do
192
- v1 = adapter.create_version(rule_id: rule_id, content: rule_content, metadata: { status: "draft" })
193
- adapter.create_version(rule_id: rule_id, content: rule_content)
194
-
195
- # Delete v1 (draft, not active)
196
- result = adapter.delete_version(version_id: v1[:id])
197
- expect(result).to be true
198
-
199
- # Verify it's deleted
200
- expect(adapter.get_version(version_id: v1[:id])).to be_nil
201
- end
202
-
203
- it "raises error when trying to delete active version" do
204
- v1 = adapter.create_version(rule_id: rule_id, content: rule_content)
205
-
206
- expect do
207
- adapter.delete_version(version_id: v1[:id])
208
- end.to raise_error(DecisionAgent::ValidationError, /Cannot delete active version/)
209
- end
210
-
211
- it "raises error for nonexistent version" do
212
- expect do
213
- adapter.delete_version(version_id: "nonexistent")
214
- end.to raise_error(DecisionAgent::NotFoundError)
215
- end
216
-
217
- it "handles file already deleted" do
218
- v1 = adapter.create_version(rule_id: rule_id, content: rule_content, metadata: { status: "draft" })
219
- adapter.create_version(rule_id: rule_id, content: rule_content)
220
-
221
- # Delete the file manually
222
- rule_dir = File.join(adapter.storage_path, rule_id)
223
- filename = "#{v1[:version_number]}.json"
224
- filepath = File.join(rule_dir, filename)
225
- FileUtils.rm_f(filepath)
226
-
227
- # Should handle gracefully
228
- result = adapter.delete_version(version_id: v1[:id])
229
- expect(result).to be false
230
- end
231
-
232
- it "converts index lookup errors to NotFoundError" do
233
- # Simulate an error during index lookup
234
- allow(adapter).to receive(:get_rule_id_from_index).and_raise(StandardError.new("Index error"))
235
-
236
- expect do
237
- adapter.delete_version(version_id: "test_version")
238
- end.to raise_error(DecisionAgent::NotFoundError, /Version not found: test_version/)
239
- end
240
-
241
- it "handles missing directory when searching for version files" do
242
- # Create a version
243
- v1 = adapter.create_version(rule_id: rule_id, content: rule_content, metadata: { status: "draft" })
244
- adapter.create_version(rule_id: rule_id, content: rule_content)
245
- version_id = v1[:id]
246
-
247
- # Manually remove the rule directory to simulate missing directory
248
- rule_dir = File.join(adapter.storage_path, rule_id)
249
- FileUtils.rm_rf(rule_dir)
250
-
251
- # The version should still be in the index, but directory is gone
252
- # This simulates a stale index entry
253
- # When delete_version is called, it will find the rule_id from index,
254
- # but the directory won't exist when searching for files
255
- result = adapter.delete_version(version_id: version_id)
256
- expect(result).to be false
257
- end
258
-
259
- it "handles version ID type mismatches with string conversion" do
260
- v1 = adapter.create_version(rule_id: rule_id, content: rule_content, metadata: { status: "draft" })
261
- adapter.create_version(rule_id: rule_id, content: rule_content)
262
-
263
- # Version IDs are stored as strings, but test that .to_s comparison works
264
- # This ensures the code handles cases where version_id might be passed as different types
265
- version_id = v1[:id]
266
- expect(version_id).to be_a(String)
267
-
268
- # Should work with string version_id
269
- result = adapter.delete_version(version_id: version_id)
270
- expect(result).to be true
271
-
272
- # Verify it's actually deleted
273
- expect(adapter.get_version(version_id: version_id)).to be_nil
274
- end
275
-
276
- it "handles file read errors gracefully when searching for version" do
277
- v1 = adapter.create_version(rule_id: rule_id, content: rule_content, metadata: { status: "draft" })
278
- adapter.create_version(rule_id: rule_id, content: rule_content)
279
-
280
- # Delete the actual version file but keep it in the index
281
- # This simulates a scenario where the file was manually deleted
282
- rule_dir = File.join(adapter.storage_path, rule_id)
283
- filename = "#{v1[:version_number]}.json"
284
- filepath = File.join(rule_dir, filename)
285
- FileUtils.rm_f(filepath)
286
-
287
- # The version should still be in the index, but the file is gone
288
- # When delete_version is called, it will:
289
- # 1. Find rule_id from index
290
- # 2. List versions - v1 won't be found (file deleted), but v2 will be
291
- # 3. Since v1 not in list, search through files
292
- # 4. Should handle missing files gracefully and return false
293
- result = adapter.delete_version(version_id: v1[:id])
294
- expect(result).to be false
295
- end
296
-
297
- it "handles case where version is in index but not in versions list and directory missing" do
298
- # Create a version
299
- v1 = adapter.create_version(rule_id: rule_id, content: rule_content, metadata: { status: "draft" })
300
- version_id = v1[:id]
301
-
302
- # Remove the directory but keep the index entry
303
- rule_dir = File.join(adapter.storage_path, rule_id)
304
- FileUtils.rm_rf(rule_dir)
305
-
306
- # This tests the path where:
307
- # 1. get_rule_id_from_index returns a rule_id (version is in index)
308
- # 2. list_versions_unsafe returns empty (directory doesn't exist)
309
- # 3. Dir.glob fails with Errno::ENOENT (directory missing)
310
- # 4. Should return false gracefully
311
- result = adapter.delete_version(version_id: version_id)
312
- expect(result).to be false
313
- end
314
-
315
- it "handles unexpected errors during lock operation and converts to NotFoundError" do
316
- v1 = adapter.create_version(rule_id: rule_id, content: rule_content, metadata: { status: "draft" })
317
-
318
- # Simulate an unexpected error during the lock operation (e.g., mutex error, file system error)
319
- allow(adapter).to receive(:list_versions_unsafe).and_raise(StandardError.new("Unexpected lock error"))
320
-
321
- # Should convert unexpected error to NotFoundError instead of letting it propagate as 500
322
- expect do
323
- adapter.delete_version(version_id: v1[:id])
324
- end.to raise_error(DecisionAgent::NotFoundError, /Version not found: #{v1[:id]}/)
325
- end
326
-
327
- it "preserves ValidationError when trying to delete active version" do
328
- v1 = adapter.create_version(rule_id: rule_id, content: rule_content)
329
-
330
- # Should raise ValidationError, not NotFoundError
331
- expect do
332
- adapter.delete_version(version_id: v1[:id])
333
- end.to raise_error(DecisionAgent::ValidationError, /Cannot delete active version/)
334
- end
335
- end
336
-
337
- describe "#list_versions_unsafe" do
338
- it "handles corrupted JSON files gracefully" do
339
- # Create a valid version
340
- v1 = adapter.create_version(rule_id: rule_id, content: rule_content)
341
-
342
- # Create a corrupted JSON file in the same directory
343
- rule_dir = File.join(adapter.storage_path, rule_id)
344
- corrupted_file = File.join(rule_dir, "999.json")
345
- File.write(corrupted_file, "invalid json content{")
346
-
347
- # Should skip corrupted files and return only valid versions
348
- versions = adapter.send(:list_versions_unsafe, rule_id: rule_id)
349
- expect(versions.length).to eq(1)
350
- expect(versions.first[:id]).to eq(v1[:id])
351
-
352
- # Clean up
353
- FileUtils.rm_f(corrupted_file)
354
- end
355
-
356
- it "handles missing files gracefully" do
357
- # Create a valid version
358
- v1 = adapter.create_version(rule_id: rule_id, content: rule_content)
359
-
360
- # Delete the file but keep directory
361
- rule_dir = File.join(adapter.storage_path, rule_id)
362
- filename = "#{v1[:version_number]}.json"
363
- filepath = File.join(rule_dir, filename)
364
- FileUtils.rm_f(filepath)
365
-
366
- # Should handle missing files gracefully
367
- versions = adapter.send(:list_versions_unsafe, rule_id: rule_id)
368
- expect(versions).to be_an(Array)
369
- # May or may not include the deleted version depending on timing
370
- end
371
- end
372
-
373
- describe "index management" do
374
- it "loads index on initialization" do
375
- v1 = adapter.create_version(rule_id: rule_id, content: rule_content)
376
-
377
- # Create new adapter instance - should load index
378
- new_adapter = DecisionAgent::Versioning::FileStorageAdapter.new(storage_path: temp_dir)
379
-
380
- # Should be able to find version using index
381
- found = new_adapter.get_version(version_id: v1[:id])
382
- expect(found).not_to be_nil
383
- end
384
-
385
- it "handles corrupted JSON files in index loading" do
386
- # Create a corrupted JSON file
387
- rule_dir = File.join(temp_dir, rule_id)
388
- FileUtils.mkdir_p(rule_dir)
389
- corrupted_file = File.join(rule_dir, "1.json")
390
- File.write(corrupted_file, "invalid json content{")
391
-
392
- # Should handle gracefully and skip corrupted files
393
- expect do
394
- _new_adapter = DecisionAgent::Versioning::FileStorageAdapter.new(storage_path: temp_dir)
395
- end.not_to raise_error
396
- end
397
-
398
- it "updates index when creating versions" do
399
- v1 = adapter.create_version(rule_id: rule_id, content: rule_content)
400
-
401
- # Index should be updated
402
- found = adapter.get_version(version_id: v1[:id])
403
- expect(found).not_to be_nil
404
- end
405
-
406
- it "removes from index when deleting versions" do
407
- v1 = adapter.create_version(rule_id: rule_id, content: rule_content, metadata: { status: "draft" })
408
- adapter.create_version(rule_id: rule_id, content: rule_content)
409
-
410
- version_id = v1[:id]
411
- adapter.delete_version(version_id: version_id)
412
-
413
- # Should not find in index
414
- expect(adapter.get_version(version_id: version_id)).to be_nil
415
- end
416
- end
417
-
418
- describe "filename sanitization" do
419
- it "sanitizes special characters in rule_id" do
420
- special_rule_id = "rule/with\\special:chars*?"
421
- version = adapter.create_version(rule_id: special_rule_id, content: rule_content)
422
-
423
- # Should create valid filename
424
- expect(version[:rule_id]).to eq(special_rule_id)
425
-
426
- # Should be able to retrieve it
427
- found = adapter.get_version(version_id: version[:id])
428
- expect(found).not_to be_nil
429
- end
430
- end
431
-
432
- describe "error handling" do
433
- it "handles update_version_status_unsafe with invalid status" do
434
- v1 = adapter.create_version(rule_id: rule_id, content: rule_content)
435
-
436
- # Try to update with invalid status via reflection (testing private method behavior)
437
- expect do
438
- adapter.send(:update_version_status_unsafe, v1[:id], "invalid_status", rule_id)
439
- end.to raise_error(DecisionAgent::ValidationError, /Invalid status/)
440
- end
441
- end
442
- end
443
-
444
- describe DecisionAgent::Versioning::VersionManager do
445
- let(:temp_dir) { Dir.mktmpdir }
446
- let(:adapter) { DecisionAgent::Versioning::FileStorageAdapter.new(storage_path: temp_dir) }
447
- let(:manager) { described_class.new(adapter: adapter) }
448
- let(:rule_id) { "test_rule_001" }
449
- let(:rule_content) do
450
- {
451
- version: "1.0",
452
- ruleset: "test_ruleset",
453
- rules: [
454
- {
455
- id: "rule_1",
456
- if: { field: "amount", op: "gt", value: 100 },
457
- then: { decision: "approve", weight: 0.8, reason: "High value" }
458
- }
459
- ]
460
- }
461
- end
462
-
463
- after do
464
- FileUtils.rm_rf(temp_dir)
465
- end
466
-
467
- describe "#save_version" do
468
- it "creates a version with metadata" do
469
- version = manager.save_version(
470
- rule_id: rule_id,
471
- rule_content: rule_content,
472
- created_by: "admin",
473
- changelog: "Initial version"
474
- )
475
-
476
- expect(version[:rule_id]).to eq(rule_id)
477
- expect(version[:content]).to eq(rule_content)
478
- expect(version[:created_by]).to eq("admin")
479
- expect(version[:changelog]).to eq("Initial version")
480
- end
481
-
482
- it "validates rule content" do
483
- expect do
484
- manager.save_version(rule_id: rule_id, rule_content: nil)
485
- end.to raise_error(DecisionAgent::ValidationError, /cannot be nil/)
486
-
487
- expect do
488
- manager.save_version(rule_id: rule_id, rule_content: "not a hash")
489
- end.to raise_error(DecisionAgent::ValidationError, /must be a Hash/)
490
-
491
- expect do
492
- manager.save_version(rule_id: rule_id, rule_content: {})
493
- end.to raise_error(DecisionAgent::ValidationError, /cannot be empty/)
494
- end
495
-
496
- it "generates default changelog if not provided" do
497
- version = manager.save_version(rule_id: rule_id, rule_content: rule_content)
498
- expect(version[:changelog]).to match(/Version \d+/)
499
- end
500
- end
501
-
502
- describe "#get_versions" do
503
- it "returns all versions for a rule" do
504
- 3.times { manager.save_version(rule_id: rule_id, rule_content: rule_content) }
505
-
506
- versions = manager.get_versions(rule_id: rule_id)
507
- expect(versions.length).to eq(3)
508
- end
509
-
510
- it "respects limit" do
511
- 5.times { manager.save_version(rule_id: rule_id, rule_content: rule_content) }
512
-
513
- versions = manager.get_versions(rule_id: rule_id, limit: 2)
514
- expect(versions.length).to eq(2)
515
- end
516
- end
517
-
518
- describe "#rollback" do
519
- it "activates a previous version without creating a duplicate" do
520
- v1 = manager.save_version(rule_id: rule_id, rule_content: rule_content, changelog: "v1")
521
- manager.save_version(rule_id: rule_id, rule_content: rule_content, changelog: "v2")
522
- manager.save_version(rule_id: rule_id, rule_content: rule_content, changelog: "v3")
523
-
524
- # Rollback to v1 should just activate it, not create a duplicate
525
- rolled_back = manager.rollback(version_id: v1[:id], performed_by: "admin")
526
-
527
- expect(rolled_back[:status]).to eq("active")
528
- expect(rolled_back[:id]).to eq(v1[:id])
529
-
530
- # Should NOT create a new version - just activate the old one
531
- versions = manager.get_versions(rule_id: rule_id)
532
- expect(versions.length).to eq(3) # Still just v1, v2, v3
533
-
534
- # v1 should be active, v2 and v3 should be archived
535
- active_version = manager.get_active_version(rule_id: rule_id)
536
- expect(active_version[:id]).to eq(v1[:id])
537
- expect(active_version[:version_number]).to eq(1)
538
- end
539
-
540
- it "maintains version history integrity after rollback" do
541
- v1 = manager.save_version(rule_id: rule_id, rule_content: rule_content.merge(data: "v1"), changelog: "Version 1")
542
- v2 = manager.save_version(rule_id: rule_id, rule_content: rule_content.merge(data: "v2"), changelog: "Version 2")
543
- v3 = manager.save_version(rule_id: rule_id, rule_content: rule_content.merge(data: "v3"), changelog: "Version 3")
544
-
545
- # Rollback to v2
546
- manager.rollback(version_id: v2[:id])
547
-
548
- # All original versions should still exist with original data
549
- loaded_v1 = manager.get_version(version_id: v1[:id])
550
- loaded_v2 = manager.get_version(version_id: v2[:id])
551
- loaded_v3 = manager.get_version(version_id: v3[:id])
552
-
553
- expect(loaded_v1[:content][:data]).to eq("v1")
554
- expect(loaded_v2[:content][:data]).to eq("v2")
555
- expect(loaded_v3[:content][:data]).to eq("v3")
556
-
557
- # v2 should be active
558
- expect(loaded_v2[:status]).to eq("active")
559
- expect(loaded_v1[:status]).to eq("archived")
560
- expect(loaded_v3[:status]).to eq("archived")
561
- end
562
- end
563
-
564
- describe "#get_history" do
565
- it "returns comprehensive history with metadata" do
566
- manager.save_version(rule_id: rule_id, rule_content: rule_content)
567
- manager.save_version(rule_id: rule_id, rule_content: rule_content)
568
-
569
- history = manager.get_history(rule_id: rule_id)
570
-
571
- expect(history[:rule_id]).to eq(rule_id)
572
- expect(history[:total_versions]).to eq(2)
573
- expect(history[:active_version]).not_to be_nil
574
- expect(history[:versions]).to be_an(Array)
575
- expect(history[:created_at]).not_to be_nil
576
- expect(history[:updated_at]).not_to be_nil
577
- end
578
- end
579
-
580
- describe "edge cases and error handling" do
581
- it "handles empty rule_id gracefully" do
582
- expect do
583
- manager.save_version(rule_id: "", rule_content: rule_content)
584
- end.not_to raise_error
585
- end
586
-
587
- it "handles special characters in rule_id" do
588
- special_rule_id = "rule-with_special.chars@123"
589
- version = manager.save_version(rule_id: special_rule_id, rule_content: rule_content)
590
-
591
- expect(version[:rule_id]).to eq(special_rule_id)
592
- end
593
-
594
- it "handles large rule content" do
595
- large_content = {
596
- version: "1.0",
597
- ruleset: "large_ruleset",
598
- rules: Array.new(1000) do |i|
599
- {
600
- id: "rule_#{i}",
601
- if: { field: "value", op: "eq", value: i },
602
- then: { decision: "approve", weight: 0.5, reason: "Rule #{i}" }
603
- }
604
- end
605
- }
606
-
607
- version = manager.save_version(rule_id: rule_id, rule_content: large_content)
608
- expect(version[:content][:rules].length).to eq(1000)
609
- end
610
-
611
- it "handles deeply nested rule structures" do
612
- nested_content = {
613
- version: "1.0",
614
- ruleset: "nested",
615
- rules: [
616
- {
617
- id: "nested_rule",
618
- if: {
619
- all: [
620
- {
621
- any: [
622
- { field: "a", op: "eq", value: 1 },
623
- { field: "b", op: "eq", value: 2 }
624
- ]
625
- },
626
- {
627
- all: [
628
- { field: "c", op: "gt", value: 3 },
629
- { field: "d", op: "lt", value: 4 }
630
- ]
631
- }
632
- ]
633
- },
634
- then: { decision: "approve", weight: 0.8, reason: "Complex rule" }
635
- }
636
- ]
637
- }
638
-
639
- version = manager.save_version(rule_id: rule_id, rule_content: nested_content)
640
- expect(version[:content][:rules].first[:if][:all]).to be_an(Array)
641
- end
642
-
643
- it "preserves exact content structure including symbols and strings" do
644
- mixed_content = {
645
- version: "1.0",
646
- ruleset: "mixed",
647
- rules: [
648
- {
649
- id: "test",
650
- metadata: {
651
- string_key: "value",
652
- number_key: 123,
653
- boolean_key: true,
654
- null_key: nil,
655
- array_key: [1, 2, 3]
656
- },
657
- if: { field: "test", op: "eq", value: "value" },
658
- then: { decision: "approve", weight: 0.5, reason: "Test" }
659
- }
660
- ]
661
- }
662
-
663
- version = manager.save_version(rule_id: rule_id, rule_content: mixed_content)
664
- loaded = manager.get_version(version_id: version[:id])
665
-
666
- expect(loaded[:content][:rules].first[:metadata]).to eq(mixed_content[:rules].first[:metadata])
667
- end
668
- end
669
-
670
- describe "concurrent version creation" do
671
- it "maintains version number sequence with concurrent saves" do
672
- threads = 10.times.map do |i|
673
- Thread.new do
674
- manager.save_version(
675
- rule_id: rule_id,
676
- rule_content: rule_content.merge(version: i.to_s),
677
- created_by: "thread_#{i}"
678
- )
679
- end
680
- end
681
-
682
- threads.each(&:join)
683
-
684
- versions = manager.get_versions(rule_id: rule_id)
685
- version_numbers = versions.map { |v| v[:version_number] }.sort
686
-
687
- expect(version_numbers).to eq((1..10).to_a)
688
- end
689
- end
690
-
691
- describe "version lifecycle" do
692
- it "tracks complete version lifecycle from draft to archived" do
693
- # Create as draft
694
- v1 = adapter.create_version(
695
- rule_id: rule_id,
696
- content: rule_content,
697
- metadata: { status: "draft" }
698
- )
699
- expect(v1[:status]).to eq("draft")
700
-
701
- # Activate
702
- adapter.activate_version(version_id: v1[:id])
703
- v1_updated = adapter.get_version(version_id: v1[:id])
704
- expect(v1_updated[:status]).to eq("active")
705
-
706
- # Create new version (archives previous)
707
- v2 = adapter.create_version(rule_id: rule_id, content: rule_content)
708
- v1_archived = adapter.get_version(version_id: v1[:id])
709
- expect(v1_archived[:status]).to eq("archived")
710
- expect(v2[:status]).to eq("active")
711
- end
712
- end
713
-
714
- describe "comparison edge cases" do
715
- it "compares identical versions" do
716
- v1 = manager.save_version(rule_id: rule_id, rule_content: rule_content)
717
- v2 = manager.save_version(rule_id: rule_id, rule_content: rule_content)
718
-
719
- comparison = manager.compare(version_id_1: v1[:id], version_id_2: v2[:id])
720
-
721
- # Should have minimal differences (just metadata changes)
722
- expect(comparison[:differences][:added]).to be_empty
723
- expect(comparison[:differences][:removed]).to be_empty
724
- end
725
-
726
- it "detects all types of changes" do
727
- content_v1 = {
728
- version: "1.0",
729
- ruleset: "test",
730
- rules: [
731
- { id: "rule_1", if: { field: "a", op: "eq", value: 1 }, then: { decision: "approve", weight: 0.8, reason: "Test" } }
732
- ]
733
- }
734
-
735
- content_v2 = {
736
- version: "2.0", # changed
737
- ruleset: "test",
738
- rules: [
739
- { id: "rule_1", if: { field: "a", op: "eq", value: 2 }, then: { decision: "reject", weight: 0.9, reason: "Updated" } }, # modified
740
- { id: "rule_2", if: { field: "b", op: "gt", value: 100 }, then: { decision: "approve", weight: 0.7, reason: "New" } } # added
741
- ],
742
- new_field: "added" # added field
743
- }
744
-
745
- v1 = manager.save_version(rule_id: rule_id, rule_content: content_v1)
746
- v2 = manager.save_version(rule_id: rule_id, rule_content: content_v2)
747
-
748
- comparison = manager.compare(version_id_1: v1[:id], version_id_2: v2[:id])
749
-
750
- expect(comparison[:differences][:added].length).to be > 0
751
- expect(comparison[:differences][:changed]).to have_key(:version)
752
- end
753
- end
754
-
755
- describe "rollback scenarios" do
756
- it "activates previous version without creating duplicates" do
757
- v1 = manager.save_version(rule_id: rule_id, rule_content: rule_content, changelog: "Version 1")
758
- manager.save_version(rule_id: rule_id, rule_content: rule_content, changelog: "Version 2")
759
- manager.save_version(rule_id: rule_id, rule_content: rule_content, changelog: "Version 3")
760
-
761
- manager.rollback(version_id: v1[:id], performed_by: "admin")
762
-
763
- history = manager.get_history(rule_id: rule_id)
764
- expect(history[:total_versions]).to eq(3) # Still just v1, v2, v3 - no duplicate
765
-
766
- # v1 should be the active version
767
- expect(history[:active_version][:id]).to eq(v1[:id])
768
- expect(history[:active_version][:changelog]).to eq("Version 1")
769
- end
770
-
771
- it "handles multiple consecutive rollbacks without duplication" do
772
- v1 = manager.save_version(rule_id: rule_id, rule_content: rule_content)
773
- v2 = manager.save_version(rule_id: rule_id, rule_content: rule_content)
774
- v3 = manager.save_version(rule_id: rule_id, rule_content: rule_content)
775
-
776
- # Rollback to v1
777
- result1 = manager.rollback(version_id: v1[:id], performed_by: "user1")
778
- expect(result1[:id]).to eq(v1[:id])
779
-
780
- # Rollback to v2
781
- result2 = manager.rollback(version_id: v2[:id], performed_by: "user2")
782
- expect(result2[:id]).to eq(v2[:id])
783
-
784
- # Rollback to v3
785
- result3 = manager.rollback(version_id: v3[:id], performed_by: "user3")
786
- expect(result3[:id]).to eq(v3[:id])
787
-
788
- history = manager.get_history(rule_id: rule_id)
789
- expect(history[:total_versions]).to eq(3) # Still just the original 3 versions
790
- expect(history[:active_version][:id]).to eq(v3[:id])
791
- end
792
- end
793
-
794
- describe "query and filtering" do
795
- it "filters versions by limit correctly" do
796
- 20.times { |i| manager.save_version(rule_id: rule_id, rule_content: rule_content, changelog: "Version #{i + 1}") }
797
-
798
- versions_5 = manager.get_versions(rule_id: rule_id, limit: 5)
799
- versions_10 = manager.get_versions(rule_id: rule_id, limit: 10)
800
-
801
- expect(versions_5.length).to eq(5)
802
- expect(versions_10.length).to eq(10)
803
-
804
- # Most recent versions should come first
805
- expect(versions_5.first[:version_number]).to eq(20)
806
- expect(versions_5.last[:version_number]).to eq(16)
807
- end
808
-
809
- it "handles versions across multiple rules" do
810
- rule_ids = %w[rule_a rule_b rule_c]
811
-
812
- rule_ids.each do |rid|
813
- 3.times { manager.save_version(rule_id: rid, rule_content: rule_content) }
814
-
815
- versions = manager.get_versions(rule_id: rid)
816
- expect(versions.length).to eq(3)
817
- expect(versions.all? { |v| v[:rule_id] == rid }).to be true
818
- end
819
- end
820
- end
821
-
822
- describe "error recovery" do
823
- it "maintains data integrity after failed save" do
824
- # This test ensures that even if there's an error, previous versions remain intact
825
- manager.save_version(rule_id: rule_id, rule_content: rule_content)
826
-
827
- begin
828
- manager.save_version(rule_id: rule_id, rule_content: nil) # This should fail
829
- rescue DecisionAgent::ValidationError
830
- # Expected error
831
- end
832
-
833
- # Previous version should still be accessible
834
- versions = manager.get_versions(rule_id: rule_id)
835
- expect(versions.length).to eq(1)
836
- expect(versions.first[:content]).to eq(rule_content)
837
- end
838
- end
839
- end
840
-
841
- describe "Integration Tests" do
842
- let(:temp_dir) { Dir.mktmpdir }
843
- let(:adapter) { DecisionAgent::Versioning::FileStorageAdapter.new(storage_path: temp_dir) }
844
- let(:manager) { DecisionAgent::Versioning::VersionManager.new(adapter: adapter) }
845
-
846
- after do
847
- FileUtils.rm_rf(temp_dir)
848
- end
849
-
850
- describe "real-world workflow" do
851
- it "handles a complete version management workflow" do
852
- # 1. Create initial rule
853
- approval_rule = {
854
- version: "1.0",
855
- ruleset: "approval_workflow",
856
- rules: [
857
- {
858
- id: "high_value",
859
- if: { field: "amount", op: "gt", value: 1000 },
860
- then: { decision: "approve", weight: 0.9, reason: "High value customer" }
861
- }
862
- ]
863
- }
864
-
865
- v1 = manager.save_version(
866
- rule_id: "approval_001",
867
- rule_content: approval_rule,
868
- created_by: "product_manager",
869
- changelog: "Initial approval rules"
870
- )
871
-
872
- expect(v1[:version_number]).to eq(1)
873
-
874
- # 2. Update threshold
875
- approval_rule[:rules].first[:if][:value] = 5000
876
- v2 = manager.save_version(
877
- rule_id: "approval_001",
878
- rule_content: approval_rule,
879
- created_by: "compliance_officer",
880
- changelog: "Increased threshold per compliance requirements"
881
- )
882
-
883
- expect(v2[:version_number]).to eq(2)
884
-
885
- # 3. Add new rule
886
- approval_rule[:rules] << {
887
- id: "fraud_check",
888
- if: { field: "fraud_score", op: "gt", value: 0.7 },
889
- then: { decision: "reject", weight: 1.0, reason: "High fraud risk" }
890
- }
891
-
892
- v3 = manager.save_version(
893
- rule_id: "approval_001",
894
- rule_content: approval_rule,
895
- created_by: "security_team",
896
- changelog: "Added fraud detection rule"
897
- )
898
-
899
- expect(v3[:version_number]).to eq(3)
900
- expect(v3[:content][:rules].length).to eq(2)
901
-
902
- # 4. Compare versions
903
- comparison = manager.compare(version_id_1: v1[:id], version_id_2: v3[:id])
904
- expect(comparison[:version_1][:version_number]).to eq(1)
905
- expect(comparison[:version_2][:version_number]).to eq(3)
906
-
907
- # 5. Rollback due to issue
908
- rolled_back = manager.rollback(
909
- version_id: v2[:id],
910
- performed_by: "incident_responder"
911
- )
912
-
913
- expect(rolled_back[:status]).to eq("active")
914
- expect(rolled_back[:id]).to eq(v2[:id])
915
-
916
- # 6. Verify history
917
- history = manager.get_history(rule_id: "approval_001")
918
- expect(history[:total_versions]).to eq(3) # v1, v2, v3 - no duplicate created
919
- expect(history[:active_version][:version_number]).to eq(2) # v2 is active
920
- end
921
- end
922
-
923
- describe "multi-rule management" do
924
- it "manages versions for multiple related rules" do
925
- rulesets = {
926
- "approval" => {
927
- version: "1.0",
928
- ruleset: "approval",
929
- rules: [{ id: "approve_1", if: { field: "amount", op: "lt", value: 100 }, then: { decision: "approve", weight: 0.8, reason: "Low amount" } }]
930
- },
931
- "rejection" => {
932
- version: "1.0",
933
- ruleset: "rejection",
934
- rules: [{ id: "reject_1", if: { field: "risk_score", op: "gt", value: 0.9 }, then: { decision: "reject", weight: 1.0, reason: "High risk" } }]
935
- },
936
- "review" => {
937
- version: "1.0",
938
- ruleset: "review",
939
- rules: [{ id: "review_1", if: { field: "amount", op: "gte", value: 10_000 }, then: { decision: "manual_review", weight: 0.9, reason: "Large transaction" } }]
940
- }
941
- }
942
-
943
- # Create versions for all rulesets
944
- rulesets.each do |name, content|
945
- manager.save_version(
946
- rule_id: name,
947
- rule_content: content,
948
- created_by: "system",
949
- changelog: "Initial #{name} rules"
950
- )
951
- end
952
-
953
- # Verify each has its own version history
954
- rulesets.each_key do |name|
955
- history = manager.get_history(rule_id: name)
956
- expect(history[:total_versions]).to eq(1)
957
- expect(history[:active_version][:rule_id]).to eq(name)
958
- end
959
- end
960
- end
961
-
962
- describe "status validation" do
963
- let(:rule_id) { "test_status_rule" }
964
- let(:rule_content) do
965
- {
966
- version: "1.0",
967
- ruleset: "test",
968
- rules: [{ id: "test", if: { field: "x", op: "eq", value: 1 }, then: { decision: "approve", weight: 0.8, reason: "Test" } }]
969
- }
970
- end
971
-
972
- it "rejects invalid status values when creating versions" do
973
- expect do
974
- adapter.create_version(
975
- rule_id: rule_id,
976
- content: rule_content,
977
- metadata: { status: "banana" }
978
- )
979
- end.to raise_error(DecisionAgent::ValidationError, /Invalid status 'banana'/)
980
-
981
- expect do
982
- adapter.create_version(
983
- rule_id: rule_id,
984
- content: rule_content,
985
- metadata: { status: "pending" }
986
- )
987
- end.to raise_error(DecisionAgent::ValidationError, /Invalid status 'pending'/)
988
-
989
- expect do
990
- adapter.create_version(
991
- rule_id: rule_id,
992
- content: rule_content,
993
- metadata: { status: "deleted" }
994
- )
995
- end.to raise_error(DecisionAgent::ValidationError, /Invalid status 'deleted'/)
996
- end
997
-
998
- it "accepts valid status values" do
999
- v1 = adapter.create_version(
1000
- rule_id: rule_id,
1001
- content: rule_content,
1002
- metadata: { status: "draft" }
1003
- )
1004
- expect(v1[:status]).to eq("draft")
1005
-
1006
- v2 = adapter.create_version(
1007
- rule_id: "rule_002",
1008
- content: rule_content,
1009
- metadata: { status: "active" }
1010
- )
1011
- expect(v2[:status]).to eq("active")
1012
-
1013
- v3 = adapter.create_version(
1014
- rule_id: "rule_003",
1015
- content: rule_content,
1016
- metadata: { status: "archived" }
1017
- )
1018
- expect(v3[:status]).to eq("archived")
1019
- end
1020
-
1021
- it "uses default status 'active' when not provided" do
1022
- version = adapter.create_version(
1023
- rule_id: rule_id,
1024
- content: rule_content
1025
- )
1026
- expect(version[:status]).to eq("active")
1027
- end
1028
- end
1029
- end
1030
- end