decision_agent 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +234 -919
- data/bin/decision_agent +5 -5
- data/lib/decision_agent/agent.rb +19 -26
- data/lib/decision_agent/audit/null_adapter.rb +1 -2
- data/lib/decision_agent/decision.rb +3 -1
- data/lib/decision_agent/dsl/condition_evaluator.rb +4 -3
- data/lib/decision_agent/dsl/rule_parser.rb +4 -6
- data/lib/decision_agent/dsl/schema_validator.rb +27 -31
- data/lib/decision_agent/errors.rb +21 -6
- data/lib/decision_agent/evaluation.rb +3 -1
- data/lib/decision_agent/evaluation_validator.rb +78 -0
- data/lib/decision_agent/evaluators/json_rule_evaluator.rb +26 -0
- data/lib/decision_agent/evaluators/static_evaluator.rb +2 -6
- data/lib/decision_agent/monitoring/alert_manager.rb +282 -0
- data/lib/decision_agent/monitoring/dashboard/public/dashboard.css +381 -0
- data/lib/decision_agent/monitoring/dashboard/public/dashboard.js +471 -0
- data/lib/decision_agent/monitoring/dashboard/public/index.html +161 -0
- data/lib/decision_agent/monitoring/dashboard_server.rb +340 -0
- data/lib/decision_agent/monitoring/metrics_collector.rb +278 -0
- data/lib/decision_agent/monitoring/monitored_agent.rb +71 -0
- data/lib/decision_agent/monitoring/prometheus_exporter.rb +247 -0
- data/lib/decision_agent/replay/replay.rb +12 -22
- data/lib/decision_agent/scoring/base.rb +1 -1
- data/lib/decision_agent/scoring/consensus.rb +5 -5
- data/lib/decision_agent/scoring/weighted_average.rb +1 -1
- data/lib/decision_agent/version.rb +1 -1
- data/lib/decision_agent/versioning/activerecord_adapter.rb +141 -0
- data/lib/decision_agent/versioning/adapter.rb +100 -0
- data/lib/decision_agent/versioning/file_storage_adapter.rb +290 -0
- data/lib/decision_agent/versioning/version_manager.rb +127 -0
- data/lib/decision_agent/web/public/app.js +318 -0
- data/lib/decision_agent/web/public/index.html +56 -1
- data/lib/decision_agent/web/public/styles.css +219 -0
- data/lib/decision_agent/web/server.rb +169 -9
- data/lib/decision_agent.rb +11 -0
- data/lib/generators/decision_agent/install/install_generator.rb +40 -0
- data/lib/generators/decision_agent/install/templates/README +47 -0
- data/lib/generators/decision_agent/install/templates/migration.rb +37 -0
- data/lib/generators/decision_agent/install/templates/rule.rb +30 -0
- data/lib/generators/decision_agent/install/templates/rule_version.rb +66 -0
- data/spec/activerecord_thread_safety_spec.rb +553 -0
- data/spec/agent_spec.rb +13 -13
- data/spec/api_contract_spec.rb +16 -16
- data/spec/audit_adapters_spec.rb +3 -3
- data/spec/comprehensive_edge_cases_spec.rb +86 -86
- data/spec/dsl_validation_spec.rb +83 -83
- data/spec/edge_cases_spec.rb +23 -23
- data/spec/examples/feedback_aware_evaluator_spec.rb +7 -7
- data/spec/examples.txt +548 -0
- data/spec/issue_verification_spec.rb +685 -0
- data/spec/json_rule_evaluator_spec.rb +15 -15
- data/spec/monitoring/alert_manager_spec.rb +378 -0
- data/spec/monitoring/metrics_collector_spec.rb +281 -0
- data/spec/monitoring/monitored_agent_spec.rb +222 -0
- data/spec/monitoring/prometheus_exporter_spec.rb +242 -0
- data/spec/replay_edge_cases_spec.rb +58 -58
- data/spec/replay_spec.rb +11 -11
- data/spec/rfc8785_canonicalization_spec.rb +215 -0
- data/spec/scoring_spec.rb +1 -1
- data/spec/spec_helper.rb +9 -0
- data/spec/thread_safety_spec.rb +482 -0
- data/spec/thread_safety_spec.rb.broken +878 -0
- data/spec/versioning_spec.rb +777 -0
- data/spec/web_ui_rack_spec.rb +135 -0
- metadata +84 -11
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "tempfile"
|
|
6
|
+
|
|
7
|
+
# rubocop:disable Lint/ConstantDefinitionInBlock
|
|
8
|
+
RSpec.describe "Issue Verification Tests" do
|
|
9
|
+
# ============================================================================
|
|
10
|
+
# ISSUE #4: Missing Database Constraints
|
|
11
|
+
# ============================================================================
|
|
12
|
+
if defined?(ActiveRecord)
|
|
13
|
+
describe "Issue #4: Database Constraints" do
|
|
14
|
+
before(:all) do
|
|
15
|
+
# Setup in-memory SQLite database with shared cache for multi-threading
|
|
16
|
+
ActiveRecord::Base.establish_connection(
|
|
17
|
+
adapter: "sqlite3",
|
|
18
|
+
database: "file::memory:?cache=shared"
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
before(:each) do
|
|
23
|
+
# Clean slate for each test
|
|
24
|
+
ActiveRecord::Base.connection.execute("DROP TABLE IF EXISTS rule_versions")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
describe "Unique constraint on [rule_id, version_number]" do
|
|
28
|
+
it "FAILS without unique constraint - allows duplicate version numbers" do
|
|
29
|
+
# Create table WITHOUT unique constraint (current state)
|
|
30
|
+
ActiveRecord::Schema.define do
|
|
31
|
+
create_table :rule_versions, force: true do |t|
|
|
32
|
+
t.string :rule_id, null: false
|
|
33
|
+
t.integer :version_number, null: false
|
|
34
|
+
t.text :content, null: false
|
|
35
|
+
t.string :created_by, null: false, default: "system"
|
|
36
|
+
t.text :changelog
|
|
37
|
+
t.string :status, null: false, default: "draft"
|
|
38
|
+
t.timestamps
|
|
39
|
+
end
|
|
40
|
+
# NOTE: NO unique index on [rule_id, version_number]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Define model
|
|
44
|
+
class TestRuleVersion1 < ActiveRecord::Base
|
|
45
|
+
self.table_name = "rule_versions"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Create duplicate version numbers - THIS SHOULD FAIL BUT DOESN'T
|
|
49
|
+
TestRuleVersion1.create!(
|
|
50
|
+
rule_id: "test_rule",
|
|
51
|
+
version_number: 1,
|
|
52
|
+
content: { test: "v1" }.to_json
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# This should fail but won't without unique constraint
|
|
56
|
+
# BUG: Allows duplicates!
|
|
57
|
+
expect do
|
|
58
|
+
TestRuleVersion1.create!(
|
|
59
|
+
rule_id: "test_rule",
|
|
60
|
+
version_number: 1, # DUPLICATE!
|
|
61
|
+
content: { test: "v1_duplicate" }.to_json
|
|
62
|
+
)
|
|
63
|
+
end.not_to raise_error
|
|
64
|
+
# Verify duplicates exist
|
|
65
|
+
duplicates = TestRuleVersion1.where(rule_id: "test_rule", version_number: 1)
|
|
66
|
+
expect(duplicates.count).to be > 1, "Expected duplicates to exist without constraint"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it "PASSES with unique constraint - prevents duplicate version numbers" do
|
|
70
|
+
# Create table WITH unique constraint (fixed state)
|
|
71
|
+
ActiveRecord::Schema.define do
|
|
72
|
+
create_table :rule_versions, force: true do |t|
|
|
73
|
+
t.string :rule_id, null: false
|
|
74
|
+
t.integer :version_number, null: false
|
|
75
|
+
t.text :content, null: false
|
|
76
|
+
t.string :created_by, null: false, default: "system"
|
|
77
|
+
t.text :changelog
|
|
78
|
+
t.string :status, null: false, default: "draft"
|
|
79
|
+
t.timestamps
|
|
80
|
+
end
|
|
81
|
+
# ✅ ADD unique constraint
|
|
82
|
+
add_index :rule_versions, %i[rule_id version_number], unique: true
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Define model
|
|
86
|
+
class TestRuleVersion2 < ActiveRecord::Base
|
|
87
|
+
self.table_name = "rule_versions"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Create first version
|
|
91
|
+
TestRuleVersion2.create!(
|
|
92
|
+
rule_id: "test_rule",
|
|
93
|
+
version_number: 1,
|
|
94
|
+
content: { test: "v1" }.to_json
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Try to create duplicate - should fail
|
|
98
|
+
expect do
|
|
99
|
+
TestRuleVersion2.create!(
|
|
100
|
+
rule_id: "test_rule",
|
|
101
|
+
version_number: 1, # DUPLICATE!
|
|
102
|
+
content: { test: "v1_duplicate" }.to_json
|
|
103
|
+
)
|
|
104
|
+
end.to raise_error(ActiveRecord::RecordNotUnique)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
it "demonstrates race condition without unique constraint" do
|
|
108
|
+
# Create table without unique constraint
|
|
109
|
+
ActiveRecord::Schema.define do
|
|
110
|
+
create_table :rule_versions, force: true do |t|
|
|
111
|
+
t.string :rule_id, null: false
|
|
112
|
+
t.integer :version_number, null: false
|
|
113
|
+
t.text :content, null: false
|
|
114
|
+
t.string :created_by, null: false, default: "system"
|
|
115
|
+
t.text :changelog
|
|
116
|
+
t.string :status, null: false, default: "draft"
|
|
117
|
+
t.timestamps
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
class TestRuleVersion3 < ActiveRecord::Base
|
|
122
|
+
self.table_name = "rule_versions"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Clear schema cache to ensure all threads see the table
|
|
126
|
+
ActiveRecord::Base.connection.schema_cache.clear!
|
|
127
|
+
TestRuleVersion3.reset_column_information
|
|
128
|
+
|
|
129
|
+
# Simulate race condition
|
|
130
|
+
threads = []
|
|
131
|
+
results = []
|
|
132
|
+
mutex = Mutex.new
|
|
133
|
+
|
|
134
|
+
begin
|
|
135
|
+
10.times do |i|
|
|
136
|
+
threads << Thread.new do
|
|
137
|
+
# Each thread needs to reopen the connection in threaded environment
|
|
138
|
+
ActiveRecord::Base.connection_pool.with_connection do
|
|
139
|
+
# Calculate next version (simulating adapter logic)
|
|
140
|
+
last = TestRuleVersion3.where(rule_id: "test_rule")
|
|
141
|
+
.order(version_number: :desc)
|
|
142
|
+
.first
|
|
143
|
+
next_version = last ? last.version_number + 1 : 1
|
|
144
|
+
|
|
145
|
+
# Create version (race window here!)
|
|
146
|
+
version = TestRuleVersion3.create!(
|
|
147
|
+
rule_id: "test_rule",
|
|
148
|
+
version_number: next_version,
|
|
149
|
+
content: { thread: i }.to_json
|
|
150
|
+
)
|
|
151
|
+
mutex.synchronize { results << version }
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
ensure
|
|
156
|
+
# CRITICAL: Join all threads before test completes to prevent table being dropped while threads are running
|
|
157
|
+
threads.each(&:join)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Check for duplicate version numbers
|
|
161
|
+
version_numbers = results.map(&:version_number).sort
|
|
162
|
+
duplicates = version_numbers.select { |v| version_numbers.count(v) > 1 }.uniq
|
|
163
|
+
|
|
164
|
+
if duplicates.any?
|
|
165
|
+
puts "\n⚠️ RACE CONDITION DETECTED: Duplicate version numbers: #{duplicates.inspect}"
|
|
166
|
+
puts " Version numbers created: #{version_numbers.inspect}"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Without constraint, we EXPECT duplicates in high concurrency
|
|
170
|
+
# This test demonstrates the problem
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
describe "Partial unique index - only one active version per rule" do
|
|
175
|
+
it "allows multiple active versions without partial unique index (BUG)" do
|
|
176
|
+
# Current migration doesn't have partial unique index
|
|
177
|
+
ActiveRecord::Schema.define do
|
|
178
|
+
create_table :rule_versions, force: true do |t|
|
|
179
|
+
t.string :rule_id, null: false
|
|
180
|
+
t.integer :version_number, null: false
|
|
181
|
+
t.text :content, null: false
|
|
182
|
+
t.string :status, default: "active", null: false
|
|
183
|
+
t.timestamps
|
|
184
|
+
end
|
|
185
|
+
add_index :rule_versions, %i[rule_id version_number], unique: true
|
|
186
|
+
# NOTE: NO partial unique index on [rule_id, status] where status='active'
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
class TestRuleVersion4 < ActiveRecord::Base
|
|
190
|
+
self.table_name = "rule_versions"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Create multiple active versions - should fail but won't
|
|
194
|
+
TestRuleVersion4.create!(
|
|
195
|
+
rule_id: "test_rule",
|
|
196
|
+
version_number: 1,
|
|
197
|
+
content: { test: "v1" }.to_json,
|
|
198
|
+
status: "active"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
# This should fail but doesn't without partial unique index
|
|
202
|
+
expect do
|
|
203
|
+
TestRuleVersion4.create!(
|
|
204
|
+
rule_id: "test_rule",
|
|
205
|
+
version_number: 2,
|
|
206
|
+
content: { test: "v2" }.to_json,
|
|
207
|
+
status: "active" # DUPLICATE ACTIVE!
|
|
208
|
+
)
|
|
209
|
+
end.not_to raise_error
|
|
210
|
+
|
|
211
|
+
# Verify multiple active versions exist (BUG!)
|
|
212
|
+
active_count = TestRuleVersion4.where(rule_id: "test_rule", status: "active").count
|
|
213
|
+
expect(active_count).to be > 1, "Expected multiple active versions without partial index"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
it "prevents multiple active versions with partial unique index (PostgreSQL only)" do
|
|
217
|
+
skip "Partial unique index requires PostgreSQL" unless ActiveRecord::Base.connection.adapter_name == "PostgreSQL"
|
|
218
|
+
|
|
219
|
+
# With partial unique index
|
|
220
|
+
ActiveRecord::Schema.define do
|
|
221
|
+
create_table :rule_versions, force: true do |t|
|
|
222
|
+
t.string :rule_id, null: false
|
|
223
|
+
t.integer :version_number, null: false
|
|
224
|
+
t.text :content, null: false
|
|
225
|
+
t.string :status, default: "active", null: false
|
|
226
|
+
t.timestamps
|
|
227
|
+
end
|
|
228
|
+
add_index :rule_versions, %i[rule_id version_number], unique: true
|
|
229
|
+
# ✅ Partial unique index (PostgreSQL only)
|
|
230
|
+
add_index :rule_versions, %i[rule_id status],
|
|
231
|
+
unique: true,
|
|
232
|
+
where: "status = 'active'",
|
|
233
|
+
name: "index_rule_versions_one_active_per_rule"
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
class TestRuleVersion5 < ActiveRecord::Base
|
|
237
|
+
self.table_name = "rule_versions"
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
TestRuleVersion5.create!(
|
|
241
|
+
rule_id: "test_rule",
|
|
242
|
+
version_number: 1,
|
|
243
|
+
content: { test: "v1" }.to_json,
|
|
244
|
+
status: "active"
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
# Try to create second active version - should fail
|
|
248
|
+
expect do
|
|
249
|
+
TestRuleVersion5.create!(
|
|
250
|
+
rule_id: "test_rule",
|
|
251
|
+
version_number: 2,
|
|
252
|
+
content: { test: "v2" }.to_json,
|
|
253
|
+
status: "active"
|
|
254
|
+
)
|
|
255
|
+
end.to raise_error(ActiveRecord::RecordNotUnique)
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# ============================================================================
|
|
262
|
+
# ISSUE #5: FileStorageAdapter - Per-Rule Mutex Performance (FIXED)
|
|
263
|
+
# ============================================================================
|
|
264
|
+
describe "Issue #5: FileStorageAdapter Per-Rule Mutex Performance" do
|
|
265
|
+
let(:temp_dir) { Dir.mktmpdir }
|
|
266
|
+
let(:adapter) { DecisionAgent::Versioning::FileStorageAdapter.new(storage_path: temp_dir) }
|
|
267
|
+
let(:rule_content) do
|
|
268
|
+
{
|
|
269
|
+
version: "1.0",
|
|
270
|
+
rules: [{ id: "r1", if: { field: "x", op: "eq", value: 1 }, then: { decision: "approve", weight: 0.8, reason: "Test" } }]
|
|
271
|
+
}
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
after { FileUtils.rm_rf(temp_dir) }
|
|
275
|
+
|
|
276
|
+
it "verifies per-rule locks allow parallel access to different rules" do
|
|
277
|
+
# Create initial versions for two different rules
|
|
278
|
+
adapter.create_version(rule_id: "rule_a", content: rule_content)
|
|
279
|
+
adapter.create_version(rule_id: "rule_b", content: rule_content)
|
|
280
|
+
|
|
281
|
+
timings = { rule_a: [], rule_b: [] }
|
|
282
|
+
mutex = Mutex.new
|
|
283
|
+
|
|
284
|
+
# Thread 1: Read rule_a with simulated slow operation
|
|
285
|
+
thread1 = Thread.new do
|
|
286
|
+
start = Time.now
|
|
287
|
+
adapter.get_active_version(rule_id: "rule_a")
|
|
288
|
+
sleep(0.1) # Simulate slow operation
|
|
289
|
+
elapsed = Time.now - start
|
|
290
|
+
mutex.synchronize { timings[:rule_a] << elapsed }
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
sleep(0.01) # Ensure thread1 starts first
|
|
294
|
+
|
|
295
|
+
# Thread 2: Read rule_b (should NOT be blocked by thread1 with per-rule locks)
|
|
296
|
+
thread2 = Thread.new do
|
|
297
|
+
start = Time.now
|
|
298
|
+
adapter.get_active_version(rule_id: "rule_b") # Different rule!
|
|
299
|
+
elapsed = Time.now - start
|
|
300
|
+
mutex.synchronize { timings[:rule_b] << elapsed }
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
thread1.join
|
|
304
|
+
thread2.join
|
|
305
|
+
|
|
306
|
+
# With per-rule mutexes, thread2 should NOT wait for thread1
|
|
307
|
+
# Expected: thread2 completes quickly (~0.01s or less), not blocked by thread1's sleep
|
|
308
|
+
puts "\n✅ Per-Rule Lock Performance:"
|
|
309
|
+
puts " Thread 1 (rule_a): #{timings[:rule_a].first.round(3)}s"
|
|
310
|
+
puts " Thread 2 (rule_b): #{timings[:rule_b].first.round(3)}s"
|
|
311
|
+
|
|
312
|
+
expect(timings[:rule_b].first).to be < 0.05,
|
|
313
|
+
"Thread reading rule_b should not be blocked by thread reading rule_a (per-rule locks)"
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
it "verifies concurrent operations on different rules run in parallel" do
|
|
317
|
+
# Create 5 different rules
|
|
318
|
+
5.times { |i| adapter.create_version(rule_id: "rule_#{i}", content: rule_content) }
|
|
319
|
+
|
|
320
|
+
operations_log = []
|
|
321
|
+
log_mutex = Mutex.new
|
|
322
|
+
|
|
323
|
+
threads = []
|
|
324
|
+
10.times do |i|
|
|
325
|
+
threads << Thread.new do
|
|
326
|
+
rule_id = "rule_#{i % 5}" # 5 different rules
|
|
327
|
+
start = Time.now
|
|
328
|
+
adapter.get_active_version(rule_id: rule_id)
|
|
329
|
+
elapsed = Time.now - start
|
|
330
|
+
log_mutex.synchronize do
|
|
331
|
+
operations_log << { rule_id: rule_id, elapsed: elapsed, thread: i }
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
threads.each(&:join)
|
|
337
|
+
|
|
338
|
+
puts "\n📊 Per-Rule Lock Concurrency:"
|
|
339
|
+
puts " Operations completed: #{operations_log.size}"
|
|
340
|
+
puts " Different rules accessed: #{operations_log.map { |op| op[:rule_id] }.uniq.size}"
|
|
341
|
+
puts " Benefit: Different rules can be accessed in parallel!"
|
|
342
|
+
|
|
343
|
+
expect(operations_log.size).to eq(10)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
it "verifies per-rule locks don't serialize operations across different rules" do
|
|
347
|
+
# Create multiple rules
|
|
348
|
+
rules_count = 5
|
|
349
|
+
rules_count.times { |i| adapter.create_version(rule_id: "rule_#{i}", content: rule_content) }
|
|
350
|
+
|
|
351
|
+
# Track which operations run concurrently
|
|
352
|
+
start_times = {}
|
|
353
|
+
end_times = {}
|
|
354
|
+
times_mutex = Mutex.new
|
|
355
|
+
|
|
356
|
+
# Run operations on different rules with artificial delays
|
|
357
|
+
threads = rules_count.times.map do |i|
|
|
358
|
+
Thread.new do
|
|
359
|
+
rule_id = "rule_#{i}"
|
|
360
|
+
times_mutex.synchronize { start_times[rule_id] = Time.now }
|
|
361
|
+
|
|
362
|
+
# Simulate some work
|
|
363
|
+
adapter.get_active_version(rule_id: rule_id)
|
|
364
|
+
sleep(0.01)
|
|
365
|
+
|
|
366
|
+
times_mutex.synchronize { end_times[rule_id] = Time.now }
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
threads.each(&:join)
|
|
370
|
+
|
|
371
|
+
# Calculate overlaps - how many operations were running at the same time
|
|
372
|
+
overlaps = 0
|
|
373
|
+
start_times.each do |rule_id, start_time|
|
|
374
|
+
end_time = end_times[rule_id]
|
|
375
|
+
# Count how many other operations overlapped with this one
|
|
376
|
+
other_overlaps = start_times.count do |other_rule_id, other_start|
|
|
377
|
+
next if other_rule_id == rule_id
|
|
378
|
+
|
|
379
|
+
other_end = end_times[other_rule_id]
|
|
380
|
+
# Check if time ranges overlap
|
|
381
|
+
(other_start <= end_time) && (start_time <= other_end)
|
|
382
|
+
end
|
|
383
|
+
overlaps += other_overlaps
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
puts "\n📊 Concurrency Verification:"
|
|
387
|
+
puts " Rules processed: #{rules_count}"
|
|
388
|
+
puts " Overlapping operations detected: #{overlaps}"
|
|
389
|
+
puts " ✅ Per-rule locks allow different rules to be accessed concurrently!"
|
|
390
|
+
|
|
391
|
+
# With per-rule locks, at least some operations should overlap
|
|
392
|
+
# (With a global mutex, there would be 0 overlaps)
|
|
393
|
+
expect(overlaps).to be > 0,
|
|
394
|
+
"Expected concurrent operations on different rules (per-rule locks), got #{overlaps} overlaps"
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# ============================================================================
|
|
399
|
+
# ISSUE #6: Missing Error Classes
|
|
400
|
+
# ============================================================================
|
|
401
|
+
describe "Issue #6: Missing Error Classes" do
|
|
402
|
+
it "verifies ConfigurationError is defined" do
|
|
403
|
+
expect(defined?(DecisionAgent::ConfigurationError)).to be_truthy,
|
|
404
|
+
"DecisionAgent::ConfigurationError is referenced but not defined"
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
it "verifies NotFoundError is defined" do
|
|
408
|
+
expect(defined?(DecisionAgent::NotFoundError)).to be_truthy,
|
|
409
|
+
"DecisionAgent::NotFoundError is referenced but not defined"
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
it "verifies ValidationError is defined" do
|
|
413
|
+
expect(defined?(DecisionAgent::ValidationError)).to be_truthy,
|
|
414
|
+
"DecisionAgent::ValidationError is referenced but not defined"
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
it "verifies all error classes inherit from DecisionAgent::Error" do
|
|
418
|
+
expect(DecisionAgent::ConfigurationError.ancestors).to include(DecisionAgent::Error)
|
|
419
|
+
expect(DecisionAgent::NotFoundError.ancestors).to include(DecisionAgent::Error)
|
|
420
|
+
expect(DecisionAgent::ValidationError.ancestors).to include(DecisionAgent::Error)
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
it "verifies all error classes inherit from StandardError" do
|
|
424
|
+
expect(DecisionAgent::ConfigurationError.ancestors).to include(StandardError)
|
|
425
|
+
expect(DecisionAgent::NotFoundError.ancestors).to include(StandardError)
|
|
426
|
+
expect(DecisionAgent::ValidationError.ancestors).to include(StandardError)
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
it "can instantiate and raise ConfigurationError" do
|
|
430
|
+
expect { raise DecisionAgent::ConfigurationError, "Test error" }
|
|
431
|
+
.to raise_error(DecisionAgent::ConfigurationError, "Test error")
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
it "can instantiate and raise NotFoundError" do
|
|
435
|
+
expect { raise DecisionAgent::NotFoundError, "Resource not found" }
|
|
436
|
+
.to raise_error(DecisionAgent::NotFoundError, "Resource not found")
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
it "can instantiate and raise ValidationError" do
|
|
440
|
+
expect { raise DecisionAgent::ValidationError, "Validation failed" }
|
|
441
|
+
.to raise_error(DecisionAgent::ValidationError, "Validation failed")
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# ============================================================================
|
|
446
|
+
# ISSUE #7: JSON Serialization Edge Cases
|
|
447
|
+
# ============================================================================
|
|
448
|
+
if defined?(ActiveRecord)
|
|
449
|
+
describe "Issue #7: JSON Serialization Edge Cases in ActiveRecordAdapter" do
|
|
450
|
+
before(:all) do
|
|
451
|
+
ActiveRecord::Base.establish_connection(
|
|
452
|
+
adapter: "sqlite3",
|
|
453
|
+
database: ":memory:"
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
ActiveRecord::Schema.define do
|
|
457
|
+
create_table :rule_versions, force: true do |t|
|
|
458
|
+
t.string :rule_id, null: false
|
|
459
|
+
t.integer :version_number, null: false
|
|
460
|
+
t.text :content, null: false
|
|
461
|
+
t.string :created_by, null: false, default: "system"
|
|
462
|
+
t.text :changelog
|
|
463
|
+
t.string :status, null: false, default: "draft"
|
|
464
|
+
t.timestamps
|
|
465
|
+
end
|
|
466
|
+
add_index :rule_versions, %i[rule_id version_number], unique: true
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
unless defined?(RuleVersion)
|
|
470
|
+
class ::RuleVersion < ActiveRecord::Base
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
before(:each) do
|
|
476
|
+
RuleVersion.delete_all
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
let(:adapter) { DecisionAgent::Versioning::ActiveRecordAdapter.new }
|
|
480
|
+
|
|
481
|
+
describe "JSON.parse error handling in serialize_version" do
|
|
482
|
+
it "raises ValidationError when content is invalid JSON" do
|
|
483
|
+
# Create a version with invalid JSON directly in database
|
|
484
|
+
version = RuleVersion.create!(
|
|
485
|
+
rule_id: "test_rule",
|
|
486
|
+
version_number: 1,
|
|
487
|
+
content: "{ invalid json", # INVALID JSON!
|
|
488
|
+
created_by: "test",
|
|
489
|
+
status: "active"
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
# serialize_version should catch JSON::ParserError and raise ValidationError
|
|
493
|
+
expect do
|
|
494
|
+
adapter.send(:serialize_version, version)
|
|
495
|
+
end.to raise_error(DecisionAgent::ValidationError, /Invalid JSON/)
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
it "raises ValidationError when content is empty string" do
|
|
499
|
+
# ActiveRecord validation prevents empty string content
|
|
500
|
+
skip "ActiveRecord validation prevents empty string content"
|
|
501
|
+
|
|
502
|
+
# This test would only be relevant if the model allowed empty strings
|
|
503
|
+
# The RuleVersion model has `validates :content, presence: true`
|
|
504
|
+
# which rejects empty strings before record creation
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
it "raises ValidationError when content is nil (if allowed by DB)" do
|
|
508
|
+
# Skip this test because the schema has NOT NULL constraint on content
|
|
509
|
+
# The database won't allow nil content to be saved in the first place
|
|
510
|
+
skip "Schema has NOT NULL constraint on content column"
|
|
511
|
+
|
|
512
|
+
# This test would only be relevant if the schema allowed NULL content
|
|
513
|
+
# In that case, the serialize_version method already handles it with:
|
|
514
|
+
# rescue TypeError, NoMethodError
|
|
515
|
+
# raise DecisionAgent::ValidationError, "content is nil or not a string"
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
it "raises ValidationError when content contains malformed UTF-8" do
|
|
519
|
+
# ActiveRecord validation rejects malformed UTF-8 before record creation
|
|
520
|
+
skip "ActiveRecord validation rejects malformed UTF-8 strings"
|
|
521
|
+
|
|
522
|
+
# This test would only be relevant if ActiveRecord allowed malformed UTF-8
|
|
523
|
+
# In practice, ActiveRecord's blank? check fails on invalid UTF-8
|
|
524
|
+
# which prevents the record from being created in the first place
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
it "raises ValidationError when content is truncated JSON" do
|
|
528
|
+
version = RuleVersion.create!(
|
|
529
|
+
rule_id: "test_rule",
|
|
530
|
+
version_number: 1,
|
|
531
|
+
content: '{"version":"1.0","rules":[{"id":"r1"', # TRUNCATED!
|
|
532
|
+
created_by: "test",
|
|
533
|
+
status: "active"
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
expect do
|
|
537
|
+
adapter.send(:serialize_version, version)
|
|
538
|
+
end.to raise_error(DecisionAgent::ValidationError, /Invalid JSON/)
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
it "raises ValidationError on get_version when JSON is invalid" do
|
|
542
|
+
version = RuleVersion.create!(
|
|
543
|
+
rule_id: "test_rule",
|
|
544
|
+
version_number: 1,
|
|
545
|
+
content: "not json",
|
|
546
|
+
created_by: "test",
|
|
547
|
+
status: "active"
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
expect do
|
|
551
|
+
adapter.get_version(version_id: version.id)
|
|
552
|
+
end.to raise_error(DecisionAgent::ValidationError, /Invalid JSON/)
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
it "raises ValidationError on get_active_version when JSON is invalid" do
|
|
556
|
+
RuleVersion.create!(
|
|
557
|
+
rule_id: "test_rule",
|
|
558
|
+
version_number: 1,
|
|
559
|
+
content: "{ broken",
|
|
560
|
+
created_by: "test",
|
|
561
|
+
status: "active"
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
expect do
|
|
565
|
+
adapter.get_active_version(rule_id: "test_rule")
|
|
566
|
+
end.to raise_error(DecisionAgent::ValidationError, /Invalid JSON/)
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
it "raises ValidationError on list_versions when any JSON is invalid" do
|
|
570
|
+
# Create valid and invalid versions
|
|
571
|
+
RuleVersion.create!(
|
|
572
|
+
rule_id: "test_rule",
|
|
573
|
+
version_number: 1,
|
|
574
|
+
content: { valid: true }.to_json,
|
|
575
|
+
created_by: "test",
|
|
576
|
+
status: "active"
|
|
577
|
+
)
|
|
578
|
+
RuleVersion.create!(
|
|
579
|
+
rule_id: "test_rule",
|
|
580
|
+
version_number: 2,
|
|
581
|
+
content: "{ invalid", # INVALID!
|
|
582
|
+
created_by: "test",
|
|
583
|
+
status: "draft"
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
# list_versions tries to serialize all versions
|
|
587
|
+
expect do
|
|
588
|
+
adapter.list_versions(rule_id: "test_rule")
|
|
589
|
+
end.to raise_error(DecisionAgent::ValidationError, /Invalid JSON/)
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
it "provides clear error messages for data corruption scenarios" do
|
|
593
|
+
# Simulate corrupted data (e.g., from manual DB edit, migration issue, etc.)
|
|
594
|
+
version = adapter.create_version(
|
|
595
|
+
rule_id: "test_rule",
|
|
596
|
+
content: { valid: "content" },
|
|
597
|
+
metadata: { created_by: "system" }
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
# Manually corrupt the content in DB
|
|
601
|
+
RuleVersion.find(version[:id]).update_column(:content, "corrupted{")
|
|
602
|
+
|
|
603
|
+
# Now operations fail with clear ValidationError messages
|
|
604
|
+
expect { adapter.get_version(version_id: version[:id]) }
|
|
605
|
+
.to raise_error(DecisionAgent::ValidationError, /Invalid JSON/)
|
|
606
|
+
expect { adapter.get_active_version(rule_id: "test_rule") }
|
|
607
|
+
.to raise_error(DecisionAgent::ValidationError, /Invalid JSON/)
|
|
608
|
+
expect { adapter.list_versions(rule_id: "test_rule") }
|
|
609
|
+
.to raise_error(DecisionAgent::ValidationError, /Invalid JSON/)
|
|
610
|
+
end
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
describe "Edge cases that should be handled gracefully" do
|
|
614
|
+
it "handles valid but unusual JSON structures" do
|
|
615
|
+
unusual_contents = [
|
|
616
|
+
[], # Empty array
|
|
617
|
+
{}, # Empty object
|
|
618
|
+
"string", # JSON string
|
|
619
|
+
123, # JSON number
|
|
620
|
+
true, # JSON boolean
|
|
621
|
+
nil # JSON null
|
|
622
|
+
]
|
|
623
|
+
|
|
624
|
+
unusual_contents.each_with_index do |content, _i|
|
|
625
|
+
version = adapter.create_version(
|
|
626
|
+
rule_id: "test_rule",
|
|
627
|
+
content: content,
|
|
628
|
+
metadata: { created_by: "test" }
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
# Should work fine
|
|
632
|
+
loaded = adapter.get_version(version_id: version[:id])
|
|
633
|
+
expect(loaded[:content]).to eq(content)
|
|
634
|
+
end
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
it "handles very large JSON content" do
|
|
638
|
+
# 10MB JSON
|
|
639
|
+
large_content = { "data" => "x" * (10 * 1024 * 1024) }
|
|
640
|
+
|
|
641
|
+
version = adapter.create_version(
|
|
642
|
+
rule_id: "test_rule",
|
|
643
|
+
content: large_content,
|
|
644
|
+
metadata: { created_by: "test" }
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
loaded = adapter.get_version(version_id: version[:id])
|
|
648
|
+
expect(loaded[:content]["data"].size).to eq(large_content["data"].size)
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
it "handles deeply nested JSON" do
|
|
652
|
+
nested = { "a" => { "b" => { "c" => { "d" => { "e" => { "f" => { "g" => { "h" => { "i" => { "j" => "deep" } } } } } } } } } }
|
|
653
|
+
|
|
654
|
+
version = adapter.create_version(
|
|
655
|
+
rule_id: "test_rule",
|
|
656
|
+
content: nested,
|
|
657
|
+
metadata: { created_by: "test" }
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
loaded = adapter.get_version(version_id: version[:id])
|
|
661
|
+
expect(loaded[:content]["a"]["b"]["c"]["d"]["e"]["f"]["g"]["h"]["i"]["j"]).to eq("deep")
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
it "handles JSON with special characters" do
|
|
665
|
+
special = {
|
|
666
|
+
"unicode" => "Hello 世界 🌍",
|
|
667
|
+
"escaped" => "Line 1\nLine 2\tTabbed",
|
|
668
|
+
"quotes" => 'He said "Hello"',
|
|
669
|
+
"backslash" => "C:\\Users\\test"
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
version = adapter.create_version(
|
|
673
|
+
rule_id: "test_rule",
|
|
674
|
+
content: special,
|
|
675
|
+
metadata: { created_by: "test" }
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
loaded = adapter.get_version(version_id: version[:id])
|
|
679
|
+
expect(loaded[:content]).to eq(special)
|
|
680
|
+
end
|
|
681
|
+
end
|
|
682
|
+
end
|
|
683
|
+
end
|
|
684
|
+
end
|
|
685
|
+
# rubocop:enable Lint/ConstantDefinitionInBlock
|