decision_agent 0.1.2 → 0.1.4

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 (87) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +212 -35
  3. data/bin/decision_agent +3 -8
  4. data/lib/decision_agent/ab_testing/ab_test.rb +197 -0
  5. data/lib/decision_agent/ab_testing/ab_test_assignment.rb +76 -0
  6. data/lib/decision_agent/ab_testing/ab_test_manager.rb +317 -0
  7. data/lib/decision_agent/ab_testing/ab_testing_agent.rb +152 -0
  8. data/lib/decision_agent/ab_testing/storage/activerecord_adapter.rb +155 -0
  9. data/lib/decision_agent/ab_testing/storage/adapter.rb +67 -0
  10. data/lib/decision_agent/ab_testing/storage/memory_adapter.rb +116 -0
  11. data/lib/decision_agent/agent.rb +19 -26
  12. data/lib/decision_agent/audit/null_adapter.rb +1 -2
  13. data/lib/decision_agent/decision.rb +3 -1
  14. data/lib/decision_agent/dsl/condition_evaluator.rb +4 -3
  15. data/lib/decision_agent/dsl/rule_parser.rb +4 -6
  16. data/lib/decision_agent/dsl/schema_validator.rb +27 -31
  17. data/lib/decision_agent/errors.rb +11 -8
  18. data/lib/decision_agent/evaluation.rb +3 -1
  19. data/lib/decision_agent/evaluation_validator.rb +78 -0
  20. data/lib/decision_agent/evaluators/json_rule_evaluator.rb +26 -0
  21. data/lib/decision_agent/evaluators/static_evaluator.rb +2 -6
  22. data/lib/decision_agent/monitoring/alert_manager.rb +282 -0
  23. data/lib/decision_agent/monitoring/dashboard/public/dashboard.css +381 -0
  24. data/lib/decision_agent/monitoring/dashboard/public/dashboard.js +471 -0
  25. data/lib/decision_agent/monitoring/dashboard/public/index.html +161 -0
  26. data/lib/decision_agent/monitoring/dashboard_server.rb +340 -0
  27. data/lib/decision_agent/monitoring/metrics_collector.rb +423 -0
  28. data/lib/decision_agent/monitoring/monitored_agent.rb +71 -0
  29. data/lib/decision_agent/monitoring/prometheus_exporter.rb +247 -0
  30. data/lib/decision_agent/monitoring/storage/activerecord_adapter.rb +253 -0
  31. data/lib/decision_agent/monitoring/storage/base_adapter.rb +90 -0
  32. data/lib/decision_agent/monitoring/storage/memory_adapter.rb +222 -0
  33. data/lib/decision_agent/replay/replay.rb +12 -22
  34. data/lib/decision_agent/scoring/base.rb +1 -1
  35. data/lib/decision_agent/scoring/consensus.rb +5 -5
  36. data/lib/decision_agent/scoring/weighted_average.rb +1 -1
  37. data/lib/decision_agent/version.rb +1 -1
  38. data/lib/decision_agent/versioning/activerecord_adapter.rb +69 -33
  39. data/lib/decision_agent/versioning/adapter.rb +1 -3
  40. data/lib/decision_agent/versioning/file_storage_adapter.rb +143 -35
  41. data/lib/decision_agent/versioning/version_manager.rb +4 -12
  42. data/lib/decision_agent/web/public/index.html +1 -1
  43. data/lib/decision_agent/web/server.rb +19 -24
  44. data/lib/decision_agent.rb +14 -0
  45. data/lib/generators/decision_agent/install/install_generator.rb +42 -5
  46. data/lib/generators/decision_agent/install/templates/ab_test_assignment_model.rb +45 -0
  47. data/lib/generators/decision_agent/install/templates/ab_test_model.rb +54 -0
  48. data/lib/generators/decision_agent/install/templates/ab_testing_migration.rb +43 -0
  49. data/lib/generators/decision_agent/install/templates/ab_testing_tasks.rake +189 -0
  50. data/lib/generators/decision_agent/install/templates/decision_agent_tasks.rake +114 -0
  51. data/lib/generators/decision_agent/install/templates/decision_log.rb +57 -0
  52. data/lib/generators/decision_agent/install/templates/error_metric.rb +53 -0
  53. data/lib/generators/decision_agent/install/templates/evaluation_metric.rb +43 -0
  54. data/lib/generators/decision_agent/install/templates/migration.rb +17 -6
  55. data/lib/generators/decision_agent/install/templates/monitoring_migration.rb +109 -0
  56. data/lib/generators/decision_agent/install/templates/performance_metric.rb +76 -0
  57. data/lib/generators/decision_agent/install/templates/rule.rb +3 -3
  58. data/lib/generators/decision_agent/install/templates/rule_version.rb +13 -7
  59. data/spec/ab_testing/ab_test_manager_spec.rb +330 -0
  60. data/spec/ab_testing/ab_test_spec.rb +270 -0
  61. data/spec/activerecord_thread_safety_spec.rb +553 -0
  62. data/spec/agent_spec.rb +13 -13
  63. data/spec/api_contract_spec.rb +16 -16
  64. data/spec/audit_adapters_spec.rb +3 -3
  65. data/spec/comprehensive_edge_cases_spec.rb +86 -86
  66. data/spec/dsl_validation_spec.rb +83 -83
  67. data/spec/edge_cases_spec.rb +23 -23
  68. data/spec/examples/feedback_aware_evaluator_spec.rb +7 -7
  69. data/spec/examples.txt +612 -0
  70. data/spec/issue_verification_spec.rb +759 -0
  71. data/spec/json_rule_evaluator_spec.rb +15 -15
  72. data/spec/monitoring/alert_manager_spec.rb +378 -0
  73. data/spec/monitoring/metrics_collector_spec.rb +281 -0
  74. data/spec/monitoring/monitored_agent_spec.rb +222 -0
  75. data/spec/monitoring/prometheus_exporter_spec.rb +242 -0
  76. data/spec/monitoring/storage/activerecord_adapter_spec.rb +346 -0
  77. data/spec/monitoring/storage/memory_adapter_spec.rb +247 -0
  78. data/spec/replay_edge_cases_spec.rb +58 -58
  79. data/spec/replay_spec.rb +11 -11
  80. data/spec/rfc8785_canonicalization_spec.rb +215 -0
  81. data/spec/scoring_spec.rb +1 -1
  82. data/spec/spec_helper.rb +9 -0
  83. data/spec/thread_safety_spec.rb +482 -0
  84. data/spec/thread_safety_spec.rb.broken +878 -0
  85. data/spec/versioning_spec.rb +141 -37
  86. data/spec/web_ui_rack_spec.rb +135 -0
  87. metadata +93 -6
@@ -0,0 +1,759 @@
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
+
258
+ it "verifies application-level constraint for single active version (all databases)" do
259
+ # For databases that don't support partial unique indexes (like SQLite),
260
+ # the application should enforce only one active version per rule
261
+
262
+ ActiveRecord::Schema.define do
263
+ create_table :rule_versions, force: true do |t|
264
+ t.string :rule_id, null: false
265
+ t.integer :version_number, null: false
266
+ t.text :content, null: false
267
+ t.string :status, default: "active", null: false
268
+ t.timestamps
269
+ end
270
+ add_index :rule_versions, %i[rule_id version_number], unique: true
271
+ end
272
+
273
+ class TestRuleVersion6 < ActiveRecord::Base
274
+ self.table_name = "rule_versions"
275
+
276
+ # Application-level validation (works on all databases)
277
+ validate :only_one_active_per_rule, if: -> { status == "active" }
278
+
279
+ def only_one_active_per_rule
280
+ existing = self.class.where(rule_id: rule_id, status: "active")
281
+ existing = existing.where.not(id: id) if persisted?
282
+ return unless existing.exists?
283
+
284
+ errors.add(:base, "Only one active version allowed per rule")
285
+ end
286
+ end
287
+
288
+ TestRuleVersion6.create!(
289
+ rule_id: "test_rule",
290
+ version_number: 1,
291
+ content: { test: "v1" }.to_json,
292
+ status: "active"
293
+ )
294
+
295
+ # Try to create second active version - should fail with validation error
296
+ expect do
297
+ TestRuleVersion6.create!(
298
+ rule_id: "test_rule",
299
+ version_number: 2,
300
+ content: { test: "v2" }.to_json,
301
+ status: "active"
302
+ )
303
+ end.to raise_error(ActiveRecord::RecordInvalid, /Only one active version allowed/)
304
+ end
305
+ end
306
+ end
307
+ end
308
+
309
+ # ============================================================================
310
+ # ISSUE #5: FileStorageAdapter - Per-Rule Mutex Performance (FIXED)
311
+ # ============================================================================
312
+ describe "Issue #5: FileStorageAdapter Per-Rule Mutex Performance" do
313
+ let(:temp_dir) { Dir.mktmpdir }
314
+ let(:adapter) { DecisionAgent::Versioning::FileStorageAdapter.new(storage_path: temp_dir) }
315
+ let(:rule_content) do
316
+ {
317
+ version: "1.0",
318
+ rules: [{ id: "r1", if: { field: "x", op: "eq", value: 1 }, then: { decision: "approve", weight: 0.8, reason: "Test" } }]
319
+ }
320
+ end
321
+
322
+ after { FileUtils.rm_rf(temp_dir) }
323
+
324
+ it "verifies per-rule locks allow parallel access to different rules" do
325
+ # Create initial versions for two different rules
326
+ adapter.create_version(rule_id: "rule_a", content: rule_content)
327
+ adapter.create_version(rule_id: "rule_b", content: rule_content)
328
+
329
+ timings = { rule_a: [], rule_b: [] }
330
+ mutex = Mutex.new
331
+
332
+ # Thread 1: Read rule_a with simulated slow operation
333
+ thread1 = Thread.new do
334
+ start = Time.now
335
+ adapter.get_active_version(rule_id: "rule_a")
336
+ sleep(0.1) # Simulate slow operation
337
+ elapsed = Time.now - start
338
+ mutex.synchronize { timings[:rule_a] << elapsed }
339
+ end
340
+
341
+ sleep(0.01) # Ensure thread1 starts first
342
+
343
+ # Thread 2: Read rule_b (should NOT be blocked by thread1 with per-rule locks)
344
+ thread2 = Thread.new do
345
+ start = Time.now
346
+ adapter.get_active_version(rule_id: "rule_b") # Different rule!
347
+ elapsed = Time.now - start
348
+ mutex.synchronize { timings[:rule_b] << elapsed }
349
+ end
350
+
351
+ thread1.join
352
+ thread2.join
353
+
354
+ # With per-rule mutexes, thread2 should NOT wait for thread1
355
+ # Expected: thread2 completes quickly (~0.01s or less), not blocked by thread1's sleep
356
+ puts "\n✅ Per-Rule Lock Performance:"
357
+ puts " Thread 1 (rule_a): #{timings[:rule_a].first.round(3)}s"
358
+ puts " Thread 2 (rule_b): #{timings[:rule_b].first.round(3)}s"
359
+
360
+ expect(timings[:rule_b].first).to be < 0.05,
361
+ "Thread reading rule_b should not be blocked by thread reading rule_a (per-rule locks)"
362
+ end
363
+
364
+ it "verifies concurrent operations on different rules run in parallel" do
365
+ # Create 5 different rules
366
+ 5.times { |i| adapter.create_version(rule_id: "rule_#{i}", content: rule_content) }
367
+
368
+ operations_log = []
369
+ log_mutex = Mutex.new
370
+
371
+ threads = []
372
+ 10.times do |i|
373
+ threads << Thread.new do
374
+ rule_id = "rule_#{i % 5}" # 5 different rules
375
+ start = Time.now
376
+ adapter.get_active_version(rule_id: rule_id)
377
+ elapsed = Time.now - start
378
+ log_mutex.synchronize do
379
+ operations_log << { rule_id: rule_id, elapsed: elapsed, thread: i }
380
+ end
381
+ end
382
+ end
383
+
384
+ threads.each(&:join)
385
+
386
+ puts "\n📊 Per-Rule Lock Concurrency:"
387
+ puts " Operations completed: #{operations_log.size}"
388
+ puts " Different rules accessed: #{operations_log.map { |op| op[:rule_id] }.uniq.size}"
389
+ puts " Benefit: Different rules can be accessed in parallel!"
390
+
391
+ expect(operations_log.size).to eq(10)
392
+ end
393
+
394
+ it "verifies per-rule locks don't serialize operations across different rules" do
395
+ # Create multiple rules
396
+ rules_count = 5
397
+ rules_count.times { |i| adapter.create_version(rule_id: "rule_#{i}", content: rule_content) }
398
+
399
+ # Track which operations run concurrently
400
+ start_times = {}
401
+ end_times = {}
402
+ times_mutex = Mutex.new
403
+
404
+ # Run operations on different rules with artificial delays
405
+ threads = rules_count.times.map do |i|
406
+ Thread.new do
407
+ rule_id = "rule_#{i}"
408
+ times_mutex.synchronize { start_times[rule_id] = Time.now }
409
+
410
+ # Simulate some work
411
+ adapter.get_active_version(rule_id: rule_id)
412
+ sleep(0.01)
413
+
414
+ times_mutex.synchronize { end_times[rule_id] = Time.now }
415
+ end
416
+ end
417
+ threads.each(&:join)
418
+
419
+ # Calculate overlaps - how many operations were running at the same time
420
+ overlaps = 0
421
+ start_times.each do |rule_id, start_time|
422
+ end_time = end_times[rule_id]
423
+ # Count how many other operations overlapped with this one
424
+ other_overlaps = start_times.count do |other_rule_id, other_start|
425
+ next if other_rule_id == rule_id
426
+
427
+ other_end = end_times[other_rule_id]
428
+ # Check if time ranges overlap
429
+ (other_start <= end_time) && (start_time <= other_end)
430
+ end
431
+ overlaps += other_overlaps
432
+ end
433
+
434
+ puts "\n📊 Concurrency Verification:"
435
+ puts " Rules processed: #{rules_count}"
436
+ puts " Overlapping operations detected: #{overlaps}"
437
+ puts " ✅ Per-rule locks allow different rules to be accessed concurrently!"
438
+
439
+ # With per-rule locks, at least some operations should overlap
440
+ # (With a global mutex, there would be 0 overlaps)
441
+ expect(overlaps).to be > 0,
442
+ "Expected concurrent operations on different rules (per-rule locks), got #{overlaps} overlaps"
443
+ end
444
+ end
445
+
446
+ # ============================================================================
447
+ # ISSUE #6: Missing Error Classes
448
+ # ============================================================================
449
+ describe "Issue #6: Missing Error Classes" do
450
+ it "verifies ConfigurationError is defined" do
451
+ expect(defined?(DecisionAgent::ConfigurationError)).to be_truthy,
452
+ "DecisionAgent::ConfigurationError is referenced but not defined"
453
+ end
454
+
455
+ it "verifies NotFoundError is defined" do
456
+ expect(defined?(DecisionAgent::NotFoundError)).to be_truthy,
457
+ "DecisionAgent::NotFoundError is referenced but not defined"
458
+ end
459
+
460
+ it "verifies ValidationError is defined" do
461
+ expect(defined?(DecisionAgent::ValidationError)).to be_truthy,
462
+ "DecisionAgent::ValidationError is referenced but not defined"
463
+ end
464
+
465
+ it "verifies all error classes inherit from DecisionAgent::Error" do
466
+ expect(DecisionAgent::ConfigurationError.ancestors).to include(DecisionAgent::Error)
467
+ expect(DecisionAgent::NotFoundError.ancestors).to include(DecisionAgent::Error)
468
+ expect(DecisionAgent::ValidationError.ancestors).to include(DecisionAgent::Error)
469
+ end
470
+
471
+ it "verifies all error classes inherit from StandardError" do
472
+ expect(DecisionAgent::ConfigurationError.ancestors).to include(StandardError)
473
+ expect(DecisionAgent::NotFoundError.ancestors).to include(StandardError)
474
+ expect(DecisionAgent::ValidationError.ancestors).to include(StandardError)
475
+ end
476
+
477
+ it "can instantiate and raise ConfigurationError" do
478
+ expect { raise DecisionAgent::ConfigurationError, "Test error" }
479
+ .to raise_error(DecisionAgent::ConfigurationError, "Test error")
480
+ end
481
+
482
+ it "can instantiate and raise NotFoundError" do
483
+ expect { raise DecisionAgent::NotFoundError, "Resource not found" }
484
+ .to raise_error(DecisionAgent::NotFoundError, "Resource not found")
485
+ end
486
+
487
+ it "can instantiate and raise ValidationError" do
488
+ expect { raise DecisionAgent::ValidationError, "Validation failed" }
489
+ .to raise_error(DecisionAgent::ValidationError, "Validation failed")
490
+ end
491
+ end
492
+
493
+ # ============================================================================
494
+ # ISSUE #7: JSON Serialization Edge Cases
495
+ # ============================================================================
496
+ if defined?(ActiveRecord)
497
+ describe "Issue #7: JSON Serialization Edge Cases in ActiveRecordAdapter" do
498
+ before(:all) do
499
+ ActiveRecord::Base.establish_connection(
500
+ adapter: "sqlite3",
501
+ database: ":memory:"
502
+ )
503
+
504
+ ActiveRecord::Schema.define do
505
+ create_table :rule_versions, force: true do |t|
506
+ t.string :rule_id, null: false
507
+ t.integer :version_number, null: false
508
+ t.text :content, null: false
509
+ t.string :created_by, null: false, default: "system"
510
+ t.text :changelog
511
+ t.string :status, null: false, default: "draft"
512
+ t.timestamps
513
+ end
514
+ add_index :rule_versions, %i[rule_id version_number], unique: true
515
+ end
516
+
517
+ if defined?(RuleVersion)
518
+ # Clear existing validations if RuleVersion was defined by another spec
519
+ RuleVersion.clear_validators!
520
+ RuleVersion.reset_callbacks(:validate)
521
+ else
522
+ class ::RuleVersion < ActiveRecord::Base
523
+ end
524
+ end
525
+ end
526
+
527
+ before(:each) do
528
+ RuleVersion.delete_all
529
+ end
530
+
531
+ let(:adapter) { DecisionAgent::Versioning::ActiveRecordAdapter.new }
532
+
533
+ describe "JSON.parse error handling in serialize_version" do
534
+ it "raises ValidationError when content is invalid JSON" do
535
+ # Create a version with invalid JSON directly in database
536
+ version = RuleVersion.create!(
537
+ rule_id: "test_rule",
538
+ version_number: 1,
539
+ content: "{ invalid json", # INVALID JSON!
540
+ created_by: "test",
541
+ status: "active"
542
+ )
543
+
544
+ # serialize_version should catch JSON::ParserError and raise ValidationError
545
+ expect do
546
+ adapter.send(:serialize_version, version)
547
+ end.to raise_error(DecisionAgent::ValidationError, /Invalid JSON/)
548
+ end
549
+
550
+ it "handles empty string content in JSON parsing" do
551
+ # Even if the database allows empty strings (no NOT NULL + no validation),
552
+ # the adapter should handle it gracefully when parsing JSON
553
+ version = RuleVersion.create!(
554
+ rule_id: "test_rule",
555
+ version_number: 1,
556
+ content: "", # EMPTY STRING!
557
+ created_by: "test",
558
+ status: "active"
559
+ )
560
+
561
+ # serialize_version should catch JSON parsing errors
562
+ expect do
563
+ adapter.send(:serialize_version, version)
564
+ end.to raise_error(DecisionAgent::ValidationError, /Invalid JSON/)
565
+ end
566
+
567
+ it "enforces NOT NULL constraint on content column" do
568
+ # The schema has NOT NULL constraint on content column
569
+ # The database should raise an error when trying to create with nil content
570
+
571
+ expect do
572
+ RuleVersion.create!(
573
+ rule_id: "test_rule",
574
+ version_number: 1,
575
+ content: nil, # NIL!
576
+ created_by: "test",
577
+ status: "active"
578
+ )
579
+ end.to raise_error(ActiveRecord::NotNullViolation)
580
+ end
581
+
582
+ it "handles content with special UTF-8 characters correctly" do
583
+ # Instead of testing malformed UTF-8 (which ActiveRecord rejects),
584
+ # test that valid UTF-8 special characters are handled correctly
585
+ special_content = {
586
+ "unicode" => "Hello \u4E16\u754C",
587
+ "emoji" => "\u{1F44D}",
588
+ "special" => "\n\t\r"
589
+ }
590
+
591
+ version = adapter.create_version(
592
+ rule_id: "test_rule",
593
+ content: special_content,
594
+ metadata: { created_by: "test" }
595
+ )
596
+
597
+ loaded = adapter.get_version(version_id: version[:id])
598
+ expect(loaded[:content]).to eq(special_content)
599
+ end
600
+
601
+ it "raises ValidationError when content is truncated JSON" do
602
+ version = RuleVersion.create!(
603
+ rule_id: "test_rule",
604
+ version_number: 1,
605
+ content: '{"version":"1.0","rules":[{"id":"r1"', # TRUNCATED!
606
+ created_by: "test",
607
+ status: "active"
608
+ )
609
+
610
+ expect do
611
+ adapter.send(:serialize_version, version)
612
+ end.to raise_error(DecisionAgent::ValidationError, /Invalid JSON/)
613
+ end
614
+
615
+ it "raises ValidationError on get_version when JSON is invalid" do
616
+ version = RuleVersion.create!(
617
+ rule_id: "test_rule",
618
+ version_number: 1,
619
+ content: "not json",
620
+ created_by: "test",
621
+ status: "active"
622
+ )
623
+
624
+ expect do
625
+ adapter.get_version(version_id: version.id)
626
+ end.to raise_error(DecisionAgent::ValidationError, /Invalid JSON/)
627
+ end
628
+
629
+ it "raises ValidationError on get_active_version when JSON is invalid" do
630
+ RuleVersion.create!(
631
+ rule_id: "test_rule",
632
+ version_number: 1,
633
+ content: "{ broken",
634
+ created_by: "test",
635
+ status: "active"
636
+ )
637
+
638
+ expect do
639
+ adapter.get_active_version(rule_id: "test_rule")
640
+ end.to raise_error(DecisionAgent::ValidationError, /Invalid JSON/)
641
+ end
642
+
643
+ it "raises ValidationError on list_versions when any JSON is invalid" do
644
+ # Create valid and invalid versions
645
+ RuleVersion.create!(
646
+ rule_id: "test_rule",
647
+ version_number: 1,
648
+ content: { valid: true }.to_json,
649
+ created_by: "test",
650
+ status: "active"
651
+ )
652
+ RuleVersion.create!(
653
+ rule_id: "test_rule",
654
+ version_number: 2,
655
+ content: "{ invalid", # INVALID!
656
+ created_by: "test",
657
+ status: "draft"
658
+ )
659
+
660
+ # list_versions tries to serialize all versions
661
+ expect do
662
+ adapter.list_versions(rule_id: "test_rule")
663
+ end.to raise_error(DecisionAgent::ValidationError, /Invalid JSON/)
664
+ end
665
+
666
+ it "provides clear error messages for data corruption scenarios" do
667
+ # Simulate corrupted data (e.g., from manual DB edit, migration issue, etc.)
668
+ version = adapter.create_version(
669
+ rule_id: "test_rule",
670
+ content: { valid: "content" },
671
+ metadata: { created_by: "system" }
672
+ )
673
+
674
+ # Manually corrupt the content in DB
675
+ RuleVersion.find(version[:id]).update_column(:content, "corrupted{")
676
+
677
+ # Now operations fail with clear ValidationError messages
678
+ expect { adapter.get_version(version_id: version[:id]) }
679
+ .to raise_error(DecisionAgent::ValidationError, /Invalid JSON/)
680
+ expect { adapter.get_active_version(rule_id: "test_rule") }
681
+ .to raise_error(DecisionAgent::ValidationError, /Invalid JSON/)
682
+ expect { adapter.list_versions(rule_id: "test_rule") }
683
+ .to raise_error(DecisionAgent::ValidationError, /Invalid JSON/)
684
+ end
685
+ end
686
+
687
+ describe "Edge cases that should be handled gracefully" do
688
+ it "handles valid but unusual JSON structures" do
689
+ unusual_contents = [
690
+ [], # Empty array
691
+ {}, # Empty object
692
+ "string", # JSON string
693
+ 123, # JSON number
694
+ true, # JSON boolean
695
+ nil # JSON null
696
+ ]
697
+
698
+ unusual_contents.each_with_index do |content, _i|
699
+ version = adapter.create_version(
700
+ rule_id: "test_rule",
701
+ content: content,
702
+ metadata: { created_by: "test" }
703
+ )
704
+
705
+ # Should work fine
706
+ loaded = adapter.get_version(version_id: version[:id])
707
+ expect(loaded[:content]).to eq(content)
708
+ end
709
+ end
710
+
711
+ it "handles very large JSON content" do
712
+ # 10MB JSON
713
+ large_content = { "data" => "x" * (10 * 1024 * 1024) }
714
+
715
+ version = adapter.create_version(
716
+ rule_id: "test_rule",
717
+ content: large_content,
718
+ metadata: { created_by: "test" }
719
+ )
720
+
721
+ loaded = adapter.get_version(version_id: version[:id])
722
+ expect(loaded[:content]["data"].size).to eq(large_content["data"].size)
723
+ end
724
+
725
+ it "handles deeply nested JSON" do
726
+ nested = { "a" => { "b" => { "c" => { "d" => { "e" => { "f" => { "g" => { "h" => { "i" => { "j" => "deep" } } } } } } } } } }
727
+
728
+ version = adapter.create_version(
729
+ rule_id: "test_rule",
730
+ content: nested,
731
+ metadata: { created_by: "test" }
732
+ )
733
+
734
+ loaded = adapter.get_version(version_id: version[:id])
735
+ expect(loaded[:content]["a"]["b"]["c"]["d"]["e"]["f"]["g"]["h"]["i"]["j"]).to eq("deep")
736
+ end
737
+
738
+ it "handles JSON with special characters" do
739
+ special = {
740
+ "unicode" => "Hello 世界 🌍",
741
+ "escaped" => "Line 1\nLine 2\tTabbed",
742
+ "quotes" => 'He said "Hello"',
743
+ "backslash" => "C:\\Users\\test"
744
+ }
745
+
746
+ version = adapter.create_version(
747
+ rule_id: "test_rule",
748
+ content: special,
749
+ metadata: { created_by: "test" }
750
+ )
751
+
752
+ loaded = adapter.get_version(version_id: version[:id])
753
+ expect(loaded[:content]).to eq(special)
754
+ end
755
+ end
756
+ end
757
+ end
758
+ end
759
+ # rubocop:enable Lint/ConstantDefinitionInBlock