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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +234 -919
  3. data/bin/decision_agent +5 -5
  4. data/lib/decision_agent/agent.rb +19 -26
  5. data/lib/decision_agent/audit/null_adapter.rb +1 -2
  6. data/lib/decision_agent/decision.rb +3 -1
  7. data/lib/decision_agent/dsl/condition_evaluator.rb +4 -3
  8. data/lib/decision_agent/dsl/rule_parser.rb +4 -6
  9. data/lib/decision_agent/dsl/schema_validator.rb +27 -31
  10. data/lib/decision_agent/errors.rb +21 -6
  11. data/lib/decision_agent/evaluation.rb +3 -1
  12. data/lib/decision_agent/evaluation_validator.rb +78 -0
  13. data/lib/decision_agent/evaluators/json_rule_evaluator.rb +26 -0
  14. data/lib/decision_agent/evaluators/static_evaluator.rb +2 -6
  15. data/lib/decision_agent/monitoring/alert_manager.rb +282 -0
  16. data/lib/decision_agent/monitoring/dashboard/public/dashboard.css +381 -0
  17. data/lib/decision_agent/monitoring/dashboard/public/dashboard.js +471 -0
  18. data/lib/decision_agent/monitoring/dashboard/public/index.html +161 -0
  19. data/lib/decision_agent/monitoring/dashboard_server.rb +340 -0
  20. data/lib/decision_agent/monitoring/metrics_collector.rb +278 -0
  21. data/lib/decision_agent/monitoring/monitored_agent.rb +71 -0
  22. data/lib/decision_agent/monitoring/prometheus_exporter.rb +247 -0
  23. data/lib/decision_agent/replay/replay.rb +12 -22
  24. data/lib/decision_agent/scoring/base.rb +1 -1
  25. data/lib/decision_agent/scoring/consensus.rb +5 -5
  26. data/lib/decision_agent/scoring/weighted_average.rb +1 -1
  27. data/lib/decision_agent/version.rb +1 -1
  28. data/lib/decision_agent/versioning/activerecord_adapter.rb +141 -0
  29. data/lib/decision_agent/versioning/adapter.rb +100 -0
  30. data/lib/decision_agent/versioning/file_storage_adapter.rb +290 -0
  31. data/lib/decision_agent/versioning/version_manager.rb +127 -0
  32. data/lib/decision_agent/web/public/app.js +318 -0
  33. data/lib/decision_agent/web/public/index.html +56 -1
  34. data/lib/decision_agent/web/public/styles.css +219 -0
  35. data/lib/decision_agent/web/server.rb +169 -9
  36. data/lib/decision_agent.rb +11 -0
  37. data/lib/generators/decision_agent/install/install_generator.rb +40 -0
  38. data/lib/generators/decision_agent/install/templates/README +47 -0
  39. data/lib/generators/decision_agent/install/templates/migration.rb +37 -0
  40. data/lib/generators/decision_agent/install/templates/rule.rb +30 -0
  41. data/lib/generators/decision_agent/install/templates/rule_version.rb +66 -0
  42. data/spec/activerecord_thread_safety_spec.rb +553 -0
  43. data/spec/agent_spec.rb +13 -13
  44. data/spec/api_contract_spec.rb +16 -16
  45. data/spec/audit_adapters_spec.rb +3 -3
  46. data/spec/comprehensive_edge_cases_spec.rb +86 -86
  47. data/spec/dsl_validation_spec.rb +83 -83
  48. data/spec/edge_cases_spec.rb +23 -23
  49. data/spec/examples/feedback_aware_evaluator_spec.rb +7 -7
  50. data/spec/examples.txt +548 -0
  51. data/spec/issue_verification_spec.rb +685 -0
  52. data/spec/json_rule_evaluator_spec.rb +15 -15
  53. data/spec/monitoring/alert_manager_spec.rb +378 -0
  54. data/spec/monitoring/metrics_collector_spec.rb +281 -0
  55. data/spec/monitoring/monitored_agent_spec.rb +222 -0
  56. data/spec/monitoring/prometheus_exporter_spec.rb +242 -0
  57. data/spec/replay_edge_cases_spec.rb +58 -58
  58. data/spec/replay_spec.rb +11 -11
  59. data/spec/rfc8785_canonicalization_spec.rb +215 -0
  60. data/spec/scoring_spec.rb +1 -1
  61. data/spec/spec_helper.rb +9 -0
  62. data/spec/thread_safety_spec.rb +482 -0
  63. data/spec/thread_safety_spec.rb.broken +878 -0
  64. data/spec/versioning_spec.rb +777 -0
  65. data/spec/web_ui_rack_spec.rb +135 -0
  66. 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