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.
- checksums.yaml +4 -4
- data/README.md +272 -7
- data/lib/decision_agent/agent.rb +72 -1
- data/lib/decision_agent/context.rb +1 -0
- data/lib/decision_agent/data_enrichment/cache/memory_adapter.rb +86 -0
- data/lib/decision_agent/data_enrichment/cache_adapter.rb +49 -0
- data/lib/decision_agent/data_enrichment/circuit_breaker.rb +135 -0
- data/lib/decision_agent/data_enrichment/client.rb +220 -0
- data/lib/decision_agent/data_enrichment/config.rb +78 -0
- data/lib/decision_agent/data_enrichment/errors.rb +36 -0
- data/lib/decision_agent/decision.rb +102 -2
- data/lib/decision_agent/dmn/feel/evaluator.rb +28 -6
- data/lib/decision_agent/dsl/condition_evaluator.rb +982 -839
- data/lib/decision_agent/dsl/schema_validator.rb +51 -13
- data/lib/decision_agent/evaluators/dmn_evaluator.rb +106 -19
- data/lib/decision_agent/evaluators/json_rule_evaluator.rb +69 -9
- data/lib/decision_agent/explainability/condition_trace.rb +83 -0
- data/lib/decision_agent/explainability/explainability_result.rb +52 -0
- data/lib/decision_agent/explainability/rule_trace.rb +39 -0
- data/lib/decision_agent/explainability/trace_collector.rb +24 -0
- data/lib/decision_agent/monitoring/alert_manager.rb +5 -1
- data/lib/decision_agent/simulation/errors.rb +18 -0
- data/lib/decision_agent/simulation/impact_analyzer.rb +498 -0
- data/lib/decision_agent/simulation/monte_carlo_simulator.rb +635 -0
- data/lib/decision_agent/simulation/replay_engine.rb +486 -0
- data/lib/decision_agent/simulation/scenario_engine.rb +318 -0
- data/lib/decision_agent/simulation/scenario_library.rb +163 -0
- data/lib/decision_agent/simulation/shadow_test_engine.rb +287 -0
- data/lib/decision_agent/simulation/what_if_analyzer.rb +1002 -0
- data/lib/decision_agent/simulation.rb +17 -0
- data/lib/decision_agent/version.rb +1 -1
- data/lib/decision_agent/versioning/activerecord_adapter.rb +23 -8
- data/lib/decision_agent/web/public/app.js +119 -0
- data/lib/decision_agent/web/public/index.html +49 -0
- data/lib/decision_agent/web/public/simulation.html +130 -0
- data/lib/decision_agent/web/public/simulation_impact.html +478 -0
- data/lib/decision_agent/web/public/simulation_replay.html +551 -0
- data/lib/decision_agent/web/public/simulation_shadow.html +546 -0
- data/lib/decision_agent/web/public/simulation_whatif.html +532 -0
- data/lib/decision_agent/web/public/styles.css +65 -0
- data/lib/decision_agent/web/server.rb +594 -23
- data/lib/decision_agent.rb +60 -2
- metadata +53 -73
- data/spec/ab_testing/ab_test_assignment_spec.rb +0 -253
- data/spec/ab_testing/ab_test_manager_spec.rb +0 -612
- data/spec/ab_testing/ab_test_spec.rb +0 -270
- data/spec/ab_testing/ab_testing_agent_spec.rb +0 -655
- data/spec/ab_testing/storage/adapter_spec.rb +0 -64
- data/spec/ab_testing/storage/memory_adapter_spec.rb +0 -485
- data/spec/activerecord_thread_safety_spec.rb +0 -553
- data/spec/advanced_operators_spec.rb +0 -3150
- data/spec/agent_spec.rb +0 -289
- data/spec/api_contract_spec.rb +0 -430
- data/spec/audit_adapters_spec.rb +0 -92
- data/spec/auth/access_audit_logger_spec.rb +0 -394
- data/spec/auth/authenticator_spec.rb +0 -112
- data/spec/auth/password_reset_spec.rb +0 -294
- data/spec/auth/permission_checker_spec.rb +0 -207
- data/spec/auth/permission_spec.rb +0 -73
- data/spec/auth/rbac_adapter_spec.rb +0 -778
- data/spec/auth/rbac_config_spec.rb +0 -82
- data/spec/auth/role_spec.rb +0 -51
- data/spec/auth/session_manager_spec.rb +0 -172
- data/spec/auth/session_spec.rb +0 -112
- data/spec/auth/user_spec.rb +0 -130
- data/spec/comprehensive_edge_cases_spec.rb +0 -1777
- data/spec/context_spec.rb +0 -127
- data/spec/decision_agent_spec.rb +0 -96
- data/spec/decision_spec.rb +0 -423
- data/spec/dmn/decision_graph_spec.rb +0 -282
- data/spec/dmn/decision_tree_spec.rb +0 -203
- data/spec/dmn/feel/errors_spec.rb +0 -18
- data/spec/dmn/feel/functions_spec.rb +0 -400
- data/spec/dmn/feel/simple_parser_spec.rb +0 -274
- data/spec/dmn/feel/types_spec.rb +0 -176
- data/spec/dmn/feel_parser_spec.rb +0 -489
- data/spec/dmn/hit_policy_spec.rb +0 -202
- data/spec/dmn/integration_spec.rb +0 -226
- data/spec/dsl/condition_evaluator_spec.rb +0 -774
- data/spec/dsl_validation_spec.rb +0 -648
- data/spec/edge_cases_spec.rb +0 -353
- data/spec/evaluation_spec.rb +0 -364
- data/spec/evaluation_validator_spec.rb +0 -165
- data/spec/examples/feedback_aware_evaluator_spec.rb +0 -460
- data/spec/examples.txt +0 -1909
- data/spec/fixtures/dmn/complex_decision.dmn +0 -81
- data/spec/fixtures/dmn/invalid_structure.dmn +0 -31
- data/spec/fixtures/dmn/simple_decision.dmn +0 -40
- data/spec/issue_verification_spec.rb +0 -759
- data/spec/json_rule_evaluator_spec.rb +0 -587
- data/spec/monitoring/alert_manager_spec.rb +0 -378
- data/spec/monitoring/metrics_collector_spec.rb +0 -501
- data/spec/monitoring/monitored_agent_spec.rb +0 -225
- data/spec/monitoring/prometheus_exporter_spec.rb +0 -242
- data/spec/monitoring/storage/activerecord_adapter_spec.rb +0 -498
- data/spec/monitoring/storage/base_adapter_spec.rb +0 -61
- data/spec/monitoring/storage/memory_adapter_spec.rb +0 -247
- data/spec/performance_optimizations_spec.rb +0 -493
- data/spec/replay_edge_cases_spec.rb +0 -699
- data/spec/replay_spec.rb +0 -210
- data/spec/rfc8785_canonicalization_spec.rb +0 -215
- data/spec/scoring_spec.rb +0 -225
- data/spec/spec_helper.rb +0 -60
- data/spec/testing/batch_test_importer_spec.rb +0 -693
- data/spec/testing/batch_test_runner_spec.rb +0 -307
- data/spec/testing/test_coverage_analyzer_spec.rb +0 -292
- data/spec/testing/test_result_comparator_spec.rb +0 -392
- data/spec/testing/test_scenario_spec.rb +0 -113
- data/spec/thread_safety_spec.rb +0 -490
- data/spec/thread_safety_spec.rb.broken +0 -878
- data/spec/versioning/adapter_spec.rb +0 -156
- data/spec/versioning_spec.rb +0 -1030
- data/spec/web/middleware/auth_middleware_spec.rb +0 -133
- data/spec/web/middleware/permission_middleware_spec.rb +0 -247
- data/spec/web_ui_rack_spec.rb +0 -2134
data/spec/versioning_spec.rb
DELETED
|
@@ -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
|