trakable 0.2.0

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 (70) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +81 -0
  3. data/CHANGELOG.md +50 -0
  4. data/LICENSE +21 -0
  5. data/README.md +330 -0
  6. data/Rakefile +16 -0
  7. data/benchmark/full_benchmark.rb +221 -0
  8. data/benchmark/integration_memory.rb +70 -0
  9. data/benchmark/memory_benchmark.rb +141 -0
  10. data/benchmark/perf_benchmark.rb +130 -0
  11. data/integration/README.md +65 -0
  12. data/integration/run_all.rb +62 -0
  13. data/integration/scenarios/01-basic-tracking/scenario.rb +51 -0
  14. data/integration/scenarios/02-revert-restoration/scenario.rb +103 -0
  15. data/integration/scenarios/03-whodunnit-tracking/scenario.rb +72 -0
  16. data/integration/scenarios/04-cleanup-retention/scenario.rb +66 -0
  17. data/integration/scenarios/05-without-tracking/scenario.rb +62 -0
  18. data/integration/scenarios/06-callback-lifecycle/scenario.rb +103 -0
  19. data/integration/scenarios/07-global-config/scenario.rb +52 -0
  20. data/integration/scenarios/08-controller-integration/scenario.rb +44 -0
  21. data/integration/scenarios/09-cleanup-max-traks/scenario.rb +58 -0
  22. data/integration/scenarios/10-model-configuration/scenario.rb +68 -0
  23. data/integration/scenarios/11-conditional-tracking/scenario.rb +48 -0
  24. data/integration/scenarios/12-metadata/scenario.rb +54 -0
  25. data/integration/scenarios/13-traks-association/scenario.rb +80 -0
  26. data/integration/scenarios/14-time-travel/scenario.rb +132 -0
  27. data/integration/scenarios/15-diffing-changeset/scenario.rb +109 -0
  28. data/integration/scenarios/16-serialization/scenario.rb +159 -0
  29. data/integration/scenarios/17-associations-tracking/scenario.rb +143 -0
  30. data/integration/scenarios/18-bulk-operations/scenario.rb +70 -0
  31. data/integration/scenarios/19-transactions/scenario.rb +89 -0
  32. data/integration/scenarios/20-performance/scenario.rb +89 -0
  33. data/integration/scenarios/21-storage-backends/scenario.rb +52 -0
  34. data/integration/scenarios/22-multi-tenancy/scenario.rb +49 -0
  35. data/integration/scenarios/23-sti/scenario.rb +58 -0
  36. data/integration/scenarios/24-edge-cases-part1/scenario.rb +86 -0
  37. data/integration/scenarios/25-edge-cases-part2/scenario.rb +74 -0
  38. data/integration/scenarios/26-edge-cases-part3/scenario.rb +76 -0
  39. data/integration/scenarios/27-api-query-interface/scenario.rb +78 -0
  40. data/integration/scenarios/28-security-compliance/scenario.rb +61 -0
  41. data/integration/scenarios/29-soft-delete/scenario.rb +43 -0
  42. data/integration/scenarios/30-custom-events/scenario.rb +45 -0
  43. data/integration/scenarios/31-gem-packaging/scenario.rb +58 -0
  44. data/integration/scenarios/32-bypass-fail-closed/scenario.rb +77 -0
  45. data/integration/scenarios/33-coexistence-standalone/scenario.rb +53 -0
  46. data/integration/scenarios/34-real-tracking/scenario.rb +254 -0
  47. data/integration/scenarios/35-revert-undo/scenario.rb +235 -0
  48. data/integration/scenarios/36-whodunnit-deep/scenario.rb +281 -0
  49. data/integration/scenarios/37-real-world-use-cases/scenario.rb +1213 -0
  50. data/integration/scenarios/38-concurrency/scenario.rb +163 -0
  51. data/integration/scenarios/39-query-scopes/scenario.rb +126 -0
  52. data/integration/scenarios/40-whodunnit-config/scenario.rb +113 -0
  53. data/integration/scenarios/41-batch-cleanup/scenario.rb +186 -0
  54. data/integration/scenarios/scenario_runner.rb +68 -0
  55. data/lib/generators/trakable/install_generator.rb +28 -0
  56. data/lib/generators/trakable/templates/create_traks_migration.rb +23 -0
  57. data/lib/generators/trakable/templates/trakable_initializer.rb +15 -0
  58. data/lib/trakable/cleanup.rb +89 -0
  59. data/lib/trakable/config.rb +22 -0
  60. data/lib/trakable/context.rb +85 -0
  61. data/lib/trakable/controller.rb +25 -0
  62. data/lib/trakable/model.rb +99 -0
  63. data/lib/trakable/railtie.rb +28 -0
  64. data/lib/trakable/revertable.rb +166 -0
  65. data/lib/trakable/tracker.rb +134 -0
  66. data/lib/trakable/trak.rb +98 -0
  67. data/lib/trakable/version.rb +5 -0
  68. data/lib/trakable.rb +51 -0
  69. data/trakable.gemspec +41 -0
  70. metadata +242 -0
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Scenario 38: Concurrency
4
+ # Tests thread-safety of Context, Tracker, and trak creation under concurrent load
5
+
6
+ require_relative '../scenario_runner'
7
+
8
+ run_scenario 'Concurrency' do
9
+ puts '=== TEST 1: Context isolation between threads ==='
10
+
11
+ results = {}
12
+ threads = 10.times.map do |i|
13
+ Thread.new do
14
+ user = "User_#{i}"
15
+ Trakable::Context.with_user(user) do
16
+ sleep(rand * 0.01) # Random delay to provoke interleaving
17
+ results[i] = Trakable::Context.whodunnit
18
+ end
19
+ end
20
+ end
21
+
22
+ threads.each(&:join)
23
+
24
+ 10.times do |i|
25
+ assert_equal "User_#{i}", results[i], "Thread #{i} saw wrong whodunnit: #{results[i]}"
26
+ end
27
+ puts ' ✓ Each thread sees its own whodunnit'
28
+
29
+ puts '=== TEST 2: Context reset does not leak between threads ==='
30
+
31
+ leak_detected = false
32
+ barrier = Queue.new
33
+
34
+ t1 = Thread.new do
35
+ Trakable::Context.with_user('ThreadA') do
36
+ barrier << :ready
37
+ sleep 0.02
38
+ leak_detected = true if Trakable::Context.whodunnit != 'ThreadA'
39
+ end
40
+ end
41
+
42
+ t2 = Thread.new do
43
+ barrier.pop # Wait for t1 to set context
44
+ Trakable::Context.with_user('ThreadB') do
45
+ sleep 0.01
46
+ end
47
+ end
48
+
49
+ [t1, t2].each(&:join)
50
+ refute leak_detected, 'Thread A context was corrupted by Thread B'
51
+ puts ' ✓ Concurrent contexts do not leak'
52
+
53
+ puts '=== TEST 3: without_tracking is thread-local ==='
54
+
55
+ tracking_states = {}
56
+
57
+ threads = 5.times.map do |i|
58
+ Thread.new do
59
+ if i.even?
60
+ Trakable::Context.without_tracking do
61
+ sleep(rand * 0.01)
62
+ tracking_states[i] = Trakable::Context.tracking_enabled?
63
+ end
64
+ else
65
+ sleep(rand * 0.01)
66
+ tracking_states[i] = Trakable::Context.tracking_enabled?
67
+ end
68
+ end
69
+ end
70
+
71
+ threads.each(&:join)
72
+
73
+ 5.times do |i|
74
+ if i.even?
75
+ assert_equal false, tracking_states[i], "Thread #{i} should have tracking disabled"
76
+ else
77
+ assert_equal true, tracking_states[i], "Thread #{i} should have tracking enabled"
78
+ end
79
+ end
80
+ puts ' ✓ without_tracking is thread-local'
81
+
82
+ puts '=== TEST 4: Concurrent Tracker.call produces independent traks ==='
83
+
84
+ records = 20.times.map do |i|
85
+ record = Object.new
86
+ record.define_singleton_method(:id) { i }
87
+ record.define_singleton_method(:class) { Struct.new(:name).new("Post") }
88
+ record.define_singleton_method(:previous_changes) { { 'title' => ["Old_#{i}", "New_#{i}"] } }
89
+ record.define_singleton_method(:attributes) { { 'id' => i, 'title' => "New_#{i}" } }
90
+ record
91
+ end
92
+
93
+ traks = []
94
+ mutex = Mutex.new
95
+
96
+ threads = records.map do |record|
97
+ Thread.new do
98
+ trak = Trakable::Tracker.call(record, 'update')
99
+ mutex.synchronize { traks << trak }
100
+ end
101
+ end
102
+
103
+ threads.each(&:join)
104
+
105
+ assert_equal 20, traks.length, "Expected 20 traks, got #{traks.length}"
106
+
107
+ item_ids = traks.map(&:item_id).sort
108
+ assert_equal (0..19).to_a, item_ids, 'Each record should have exactly one trak'
109
+ puts ' ✓ 20 concurrent Tracker.call produce 20 independent traks'
110
+
111
+ puts '=== TEST 5: Concurrent whodunnit assignment per thread ==='
112
+
113
+ traks = []
114
+ mutex = Mutex.new
115
+
116
+ threads = 10.times.map do |i|
117
+ Thread.new do
118
+ actor = Struct.new(:id, :class).new(i, Struct.new(:name).new("Actor"))
119
+ Trakable::Context.with_user(actor) do
120
+ record = Object.new
121
+ record.define_singleton_method(:id) { i }
122
+ record.define_singleton_method(:class) { Struct.new(:name).new("Post") }
123
+ record.define_singleton_method(:previous_changes) { { 'title' => ['a', 'b'] } }
124
+ record.define_singleton_method(:attributes) { { 'id' => i, 'title' => 'b' } }
125
+
126
+ trak = Trakable::Tracker.call(record, 'update')
127
+ mutex.synchronize { traks << trak }
128
+ end
129
+ end
130
+ end
131
+
132
+ threads.each(&:join)
133
+
134
+ traks.each do |trak|
135
+ assert_equal trak.item_id, trak.whodunnit_id,
136
+ "Trak for item #{trak.item_id} has whodunnit #{trak.whodunnit_id}"
137
+ end
138
+ puts ' ✓ Each thread whodunnit is correctly paired with its trak'
139
+
140
+ puts '=== TEST 6: Stress test — 100 threads ==='
141
+
142
+ traks = []
143
+ mutex = Mutex.new
144
+
145
+ threads = 100.times.map do |i|
146
+ Thread.new do
147
+ record = Object.new
148
+ record.define_singleton_method(:id) { i }
149
+ record.define_singleton_method(:class) { Struct.new(:name).new("StressModel") }
150
+ record.define_singleton_method(:previous_changes) { {} }
151
+ record.define_singleton_method(:attributes) { { 'id' => i } }
152
+
153
+ trak = Trakable::Tracker.call(record, 'create')
154
+ mutex.synchronize { traks << trak }
155
+ end
156
+ end
157
+
158
+ threads.each(&:join)
159
+
160
+ assert_equal 100, traks.length
161
+ assert_equal 100, traks.map(&:item_id).uniq.length, 'All 100 traks should be unique'
162
+ puts ' ✓ 100 concurrent threads produce 100 unique traks'
163
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Scenario 39: Query Scopes
4
+ # Tests the AR-only scopes defined on Trakable::Trak
5
+ # Since integration tests run without AR, we verify the non-AR path
6
+ # and simulate the scope logic to validate correctness.
7
+
8
+ require_relative '../scenario_runner'
9
+
10
+ run_scenario 'Query Scopes' do
11
+ puts '=== TEST 1: Scopes are not defined in non-AR mode ==='
12
+
13
+ refute Trakable::Trak.respond_to?(:for_item_type), 'for_item_type should not exist without AR'
14
+ refute Trakable::Trak.respond_to?(:for_event), 'for_event should not exist without AR'
15
+ refute Trakable::Trak.respond_to?(:for_whodunnit), 'for_whodunnit should not exist without AR'
16
+ refute Trakable::Trak.respond_to?(:created_before), 'created_before should not exist without AR'
17
+ refute Trakable::Trak.respond_to?(:created_after), 'created_after should not exist without AR'
18
+ refute Trakable::Trak.respond_to?(:recent), 'recent should not exist without AR'
19
+ puts ' ✓ Scopes are AR-only (not defined in plain Ruby mode)'
20
+
21
+ puts '=== TEST 2: Scope logic — for_item_type ==='
22
+
23
+ traks = [
24
+ Trakable::Trak.new(item_type: 'Post', item_id: 1, event: 'create'),
25
+ Trakable::Trak.new(item_type: 'Post', item_id: 2, event: 'update'),
26
+ Trakable::Trak.new(item_type: 'Comment', item_id: 1, event: 'create'),
27
+ Trakable::Trak.new(item_type: 'User', item_id: 1, event: 'destroy')
28
+ ]
29
+
30
+ post_traks = traks.select { |t| t.item_type == 'Post' }
31
+ assert_equal 2, post_traks.length
32
+ puts ' ✓ for_item_type filters by item_type'
33
+
34
+ puts '=== TEST 3: Scope logic — for_event ==='
35
+
36
+ creates = traks.select { |t| t.event == 'create' }
37
+ assert_equal 2, creates.length
38
+
39
+ updates = traks.select { |t| t.event == 'update' }
40
+ assert_equal 1, updates.length
41
+
42
+ destroys = traks.select { |t| t.event == 'destroy' }
43
+ assert_equal 1, destroys.length
44
+ puts ' ✓ for_event filters by event type'
45
+
46
+ puts '=== TEST 4: Scope logic — for_whodunnit (polymorphic) ==='
47
+
48
+ traks_with_actors = [
49
+ Trakable::Trak.new(item_type: 'Post', item_id: 1, event: 'create',
50
+ whodunnit_type: 'User', whodunnit_id: 1),
51
+ Trakable::Trak.new(item_type: 'Post', item_id: 2, event: 'update',
52
+ whodunnit_type: 'User', whodunnit_id: 2),
53
+ Trakable::Trak.new(item_type: 'Post', item_id: 3, event: 'create',
54
+ whodunnit_type: 'Admin', whodunnit_id: 1),
55
+ Trakable::Trak.new(item_type: 'Post', item_id: 4, event: 'destroy',
56
+ whodunnit_type: nil, whodunnit_id: nil)
57
+ ]
58
+
59
+ user1_traks = traks_with_actors.select { |t| t.whodunnit_type == 'User' && t.whodunnit_id == 1 }
60
+ assert_equal 1, user1_traks.length
61
+
62
+ admin_traks = traks_with_actors.select { |t| t.whodunnit_type == 'Admin' }
63
+ assert_equal 1, admin_traks.length
64
+
65
+ anonymous_traks = traks_with_actors.select { |t| t.whodunnit_type.nil? }
66
+ assert_equal 1, anonymous_traks.length
67
+ puts ' ✓ for_whodunnit filters by type + id (polymorphic)'
68
+
69
+ puts '=== TEST 5: Scope logic — created_before / created_after ==='
70
+
71
+ now = Time.now
72
+ traks_timed = [
73
+ Trakable::Trak.new(item_type: 'Post', item_id: 1, event: 'create', created_at: now - 7200),
74
+ Trakable::Trak.new(item_type: 'Post', item_id: 2, event: 'update', created_at: now - 3600),
75
+ Trakable::Trak.new(item_type: 'Post', item_id: 3, event: 'update', created_at: now - 1800),
76
+ Trakable::Trak.new(item_type: 'Post', item_id: 4, event: 'destroy', created_at: now)
77
+ ]
78
+
79
+ cutoff = now - 3600
80
+
81
+ before = traks_timed.select { |t| t.created_at < cutoff }
82
+ assert_equal 1, before.length
83
+ assert_equal 1, before.first.item_id
84
+
85
+ after = traks_timed.select { |t| t.created_at > cutoff }
86
+ assert_equal 2, after.length
87
+ puts ' ✓ created_before / created_after filter by timestamp'
88
+
89
+ puts '=== TEST 6: Scope logic — recent (order desc) ==='
90
+
91
+ sorted = traks_timed.sort_by { |t| -t.created_at.to_f }
92
+ assert_equal 4, sorted.first.item_id
93
+ assert_equal 1, sorted.last.item_id
94
+ puts ' ✓ recent orders by created_at desc'
95
+
96
+ puts '=== TEST 7: Scope chaining simulation ==='
97
+
98
+ all_traks = [
99
+ Trakable::Trak.new(item_type: 'Post', item_id: 1, event: 'create', created_at: now - 7200,
100
+ whodunnit_type: 'User', whodunnit_id: 1),
101
+ Trakable::Trak.new(item_type: 'Post', item_id: 2, event: 'update', created_at: now - 3600,
102
+ whodunnit_type: 'User', whodunnit_id: 1),
103
+ Trakable::Trak.new(item_type: 'Comment', item_id: 1, event: 'update', created_at: now - 1800,
104
+ whodunnit_type: 'User', whodunnit_id: 1),
105
+ Trakable::Trak.new(item_type: 'Post', item_id: 3, event: 'update', created_at: now,
106
+ whodunnit_type: 'Admin', whodunnit_id: 2)
107
+ ]
108
+
109
+ # Simulate: for_item_type('Post').for_event(:update).created_after(1.day.ago).recent
110
+ result = all_traks
111
+ .select { |t| t.item_type == 'Post' }
112
+ .select { |t| t.event == 'update' }
113
+ .select { |t| t.created_at > now - 86_400 }
114
+ .sort_by { |t| -t.created_at.to_f }
115
+
116
+ assert_equal 2, result.length
117
+ assert_equal 3, result.first.item_id, 'Most recent should be first'
118
+ assert_equal 2, result.last.item_id
119
+ puts ' ✓ Chained scopes filter and sort correctly'
120
+
121
+ puts '=== TEST 8: Edge case — empty result ==='
122
+
123
+ empty = all_traks.select { |t| t.item_type == 'NonExistent' }
124
+ assert_equal 0, empty.length
125
+ puts ' ✓ Scopes return empty array when nothing matches'
126
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Scenario 40: Whodunnit Config
4
+ # Tests config.whodunnit_method global configuration
5
+
6
+ require_relative '../scenario_runner'
7
+
8
+ run_scenario 'Whodunnit Config' do
9
+ puts '=== TEST 1: Default whodunnit_method is :current_user ==='
10
+
11
+ assert_equal :current_user, Trakable.configuration.whodunnit_method
12
+ puts ' ✓ Default is :current_user'
13
+
14
+ puts '=== TEST 2: whodunnit_method is configurable ==='
15
+
16
+ Trakable.configure do |config|
17
+ config.whodunnit_method = :current_admin
18
+ end
19
+
20
+ assert_equal :current_admin, Trakable.configuration.whodunnit_method
21
+ puts ' ✓ Changed to :current_admin'
22
+
23
+ puts '=== TEST 3: Controller reads from global config ==='
24
+
25
+ # Simulate a controller that uses the configured method
26
+ controller_class = Class.new do
27
+ attr_accessor :current_admin
28
+
29
+ def whodunnit_method
30
+ Trakable.configuration.whodunnit_method
31
+ end
32
+
33
+ def perform_action(&block)
34
+ user = send(whodunnit_method)
35
+ Trakable.with_user(user, &block)
36
+ end
37
+ end
38
+
39
+ admin = Struct.new(:id, :class).new(42, Struct.new(:name).new('Admin'))
40
+ controller = controller_class.new
41
+ controller.current_admin = admin
42
+
43
+ controller.perform_action do
44
+ assert_equal admin, Trakable::Context.whodunnit
45
+ end
46
+ puts ' ✓ Controller uses configured method'
47
+
48
+ puts '=== TEST 4: Tracker picks up whodunnit from context ==='
49
+
50
+ actor = Struct.new(:id, :class).new(99, Struct.new(:name).new('Admin'))
51
+
52
+ Trakable::Context.with_user(actor) do
53
+ record = Object.new
54
+ record.define_singleton_method(:id) { 1 }
55
+ record.define_singleton_method(:class) { Struct.new(:name).new('Post') }
56
+ record.define_singleton_method(:previous_changes) { { 'title' => %w[Old New] } }
57
+ record.define_singleton_method(:attributes) { { 'id' => 1, 'title' => 'New' } }
58
+
59
+ trak = Trakable::Tracker.call(record, 'update')
60
+
61
+ assert_equal 'Admin', trak.whodunnit_type
62
+ assert_equal 99, trak.whodunnit_id
63
+ end
64
+ puts ' ✓ Tracker records whodunnit from context'
65
+
66
+ puts '=== TEST 5: Different whodunnit types (polymorphic) ==='
67
+
68
+ actor_types = [
69
+ Struct.new(:id, :class).new(1, Struct.new(:name).new('User')),
70
+ Struct.new(:id, :class).new(2, Struct.new(:name).new('Admin')),
71
+ Struct.new(:id, :class).new(3, Struct.new(:name).new('ApiKey')),
72
+ Struct.new(:id, :class).new(4, Struct.new(:name).new('ServiceAccount'))
73
+ ]
74
+
75
+ traks = actor_types.map do |actor|
76
+ Trakable::Context.with_user(actor) do
77
+ record = Object.new
78
+ record.define_singleton_method(:id) { actor.id }
79
+ record.define_singleton_method(:class) { Struct.new(:name).new('Post') }
80
+ record.define_singleton_method(:previous_changes) { { 'x' => [0, 1] } }
81
+ record.define_singleton_method(:attributes) { { 'id' => actor.id } }
82
+
83
+ Trakable::Tracker.call(record, 'update')
84
+ end
85
+ end
86
+
87
+ types = traks.map(&:whodunnit_type)
88
+ assert_equal %w[User Admin ApiKey ServiceAccount], types
89
+ puts ' ✓ Works with User, Admin, ApiKey, ServiceAccount'
90
+
91
+ puts '=== TEST 6: Nil whodunnit (anonymous/system changes) ==='
92
+
93
+ record = Object.new
94
+ record.define_singleton_method(:id) { 1 }
95
+ record.define_singleton_method(:class) { Struct.new(:name).new('Post') }
96
+ record.define_singleton_method(:previous_changes) { { 'status' => [0, 1] } }
97
+ record.define_singleton_method(:attributes) { { 'id' => 1 } }
98
+
99
+ trak = Trakable::Tracker.call(record, 'update')
100
+
101
+ assert_nil trak.whodunnit_type
102
+ assert_nil trak.whodunnit_id
103
+ puts ' ✓ Anonymous changes have nil whodunnit'
104
+
105
+ puts '=== TEST 7: Reset config ==='
106
+
107
+ Trakable.configure do |config|
108
+ config.whodunnit_method = :current_user
109
+ end
110
+
111
+ assert_equal :current_user, Trakable.configuration.whodunnit_method
112
+ puts ' ✓ Config reset to default'
113
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Scenario 41: Batch Cleanup
4
+ # Tests Cleanup.run_retention with batch deletion and Cleanup.run per-record
5
+
6
+ require_relative '../scenario_runner'
7
+
8
+ # Mock AR-like relation that supports limit + delete_all for batch testing
9
+ class BatchMockRelation
10
+ attr_reader :deleted_count, :delete_calls
11
+
12
+ def initialize(total_rows)
13
+ @remaining = total_rows
14
+ @deleted_count = 0
15
+ @delete_calls = 0
16
+ end
17
+
18
+ def where(*)
19
+ self
20
+ end
21
+
22
+ def limit(n)
23
+ @current_limit = n
24
+ self
25
+ end
26
+
27
+ def delete_all
28
+ @delete_calls += 1
29
+ to_delete = [@remaining, @current_limit || @remaining].min
30
+ @remaining -= to_delete
31
+ @deleted_count += to_delete
32
+ to_delete
33
+ end
34
+
35
+ def respond_to?(method, *)
36
+ %i[where limit delete_all].include?(method) || super
37
+ end
38
+ end
39
+
40
+ # Mock Trak class that returns our mock relation
41
+ class BatchMockTrak
42
+ class << self
43
+ attr_accessor :relation
44
+
45
+ def where(*)
46
+ relation
47
+ end
48
+
49
+ def respond_to?(method, *)
50
+ %i[where].include?(method) || super
51
+ end
52
+
53
+ def arel_table
54
+ @arel_table ||= Struct.new(:nothing).new.tap do |at|
55
+ at.define_singleton_method(:[]) { |_col| Struct.new(:lt).new(proc { |_t| true }) }
56
+ end
57
+ end
58
+ end
59
+ end
60
+
61
+ # Mock record with traks for per-record cleanup
62
+ class BatchCleanupRecord
63
+ attr_reader :trakable_options, :traks_data
64
+
65
+ def initialize(max_traks:, trak_count:)
66
+ @trakable_options = { max_traks: max_traks }
67
+ @traks_data = trak_count.times.map { |i| { id: i, created_at: Time.now - (trak_count - i) } }
68
+ end
69
+
70
+ def traks
71
+ BatchRecordRelation.new(self)
72
+ end
73
+ end
74
+
75
+ class BatchRecordRelation
76
+ def initialize(record)
77
+ @record = record
78
+ end
79
+
80
+ def respond_to?(method, *)
81
+ %i[where order offset delete_all].include?(method) || super
82
+ end
83
+
84
+ def order(*)
85
+ @sorted = @record.traks_data.sort_by { |t| -t[:created_at].to_f }
86
+ self
87
+ end
88
+
89
+ def offset(n)
90
+ @sorted = (@sorted || @record.traks_data).drop(n)
91
+ self
92
+ end
93
+
94
+ def delete_all
95
+ ids_to_delete = @sorted.map { |t| t[:id] }
96
+ @record.traks_data.reject! { |t| ids_to_delete.include?(t[:id]) }
97
+ ids_to_delete.length
98
+ end
99
+ end
100
+
101
+ run_scenario 'Batch Cleanup' do
102
+ puts '=== TEST 1: run_retention returns nil without retention config ==='
103
+
104
+ model_class = Class.new do
105
+ def self.trakable_options
106
+ {}
107
+ end
108
+ end
109
+
110
+ result = Trakable::Cleanup.run_retention(model_class)
111
+ assert_nil result
112
+ puts ' ✓ Returns nil when no retention configured'
113
+
114
+ puts '=== TEST 2: run_retention returns 0 when no old traks ==='
115
+
116
+ model_with_retention = Class.new do
117
+ def self.trakable_options
118
+ { retention: 90 * 86_400 }
119
+ end
120
+
121
+ def self.to_s
122
+ 'CleanModel'
123
+ end
124
+ end
125
+
126
+ result = Trakable::Cleanup.run_retention(model_with_retention)
127
+ assert_equal 0, result
128
+ puts ' ✓ Returns 0 when nothing to delete'
129
+
130
+ puts '=== TEST 3: run_retention accepts custom batch_size ==='
131
+
132
+ result = Trakable::Cleanup.run_retention(model_with_retention, batch_size: 500)
133
+ assert_equal 0, result
134
+ puts ' ✓ batch_size parameter accepted'
135
+
136
+ puts '=== TEST 4: run_retention accepts override retention_period ==='
137
+
138
+ model_no_retention = Class.new do
139
+ def self.trakable_options
140
+ {}
141
+ end
142
+
143
+ def self.to_s
144
+ 'NoRetentionModel'
145
+ end
146
+ end
147
+
148
+ result = Trakable::Cleanup.run_retention(model_no_retention, retention_period: 30 * 86_400)
149
+ # Should run (not nil) because we override
150
+ assert_equal 0, result
151
+ puts ' ✓ retention_period override works'
152
+
153
+ puts '=== TEST 5: BATCH_SIZE constant is defined ==='
154
+
155
+ assert_equal 1_000, Trakable::Cleanup::BATCH_SIZE
156
+ puts ' ✓ Default BATCH_SIZE is 1,000'
157
+
158
+ puts '=== TEST 6: Per-record cleanup enforces max_traks ==='
159
+
160
+ record = BatchCleanupRecord.new(max_traks: 3, trak_count: 10)
161
+ assert_equal 10, record.traks_data.length
162
+
163
+ Trakable::Cleanup.run(record)
164
+
165
+ assert_equal 3, record.traks_data.length, "Expected 3 traks, got #{record.traks_data.length}"
166
+ # Should keep the 3 most recent (highest IDs)
167
+ kept_ids = record.traks_data.map { |t| t[:id] }.sort
168
+ assert_equal [7, 8, 9], kept_ids
169
+ puts ' ✓ max_traks keeps only the N most recent'
170
+
171
+ puts '=== TEST 7: Per-record cleanup is a no-op when under limit ==='
172
+
173
+ record = BatchCleanupRecord.new(max_traks: 10, trak_count: 3)
174
+ Trakable::Cleanup.run(record)
175
+
176
+ assert_equal 3, record.traks_data.length
177
+ puts ' ✓ No deletion when trak count is under max_traks'
178
+
179
+ puts '=== TEST 8: Cleanup.run returns true ==='
180
+
181
+ record = BatchCleanupRecord.new(max_traks: 5, trak_count: 5)
182
+ result = Trakable::Cleanup.run(record)
183
+
184
+ assert_equal true, result
185
+ puts ' ✓ Cleanup.run always returns true'
186
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../test/test_helper'
4
+ require 'securerandom'
5
+ require 'active_support/core_ext/integer/time'
6
+ require 'active_support/core_ext/numeric/time'
7
+
8
+ # Scenario runner helper
9
+ # Include this in your scenario files
10
+
11
+ def run_scenario(name)
12
+ puts "\n#{'=' * 60}"
13
+ puts "SCENARIO: #{name}"
14
+ puts '=' * 60
15
+ yield
16
+ puts "\n#{'=' * 60}"
17
+ puts "SCENARIO #{name} COMPLETE"
18
+ puts '=' * 60
19
+ rescue StandardError => e
20
+ puts "\n#{'!' * 60}"
21
+ puts "SCENARIO FAILED: #{e.message}"
22
+ puts e.backtrace.first(5).join("\n")
23
+ puts '!' * 60
24
+ raise e
25
+ end
26
+
27
+ def assert(condition, message = 'Assertion failed')
28
+ raise message unless condition
29
+ end
30
+
31
+ def assert_equal(expected, actual, message = nil)
32
+ msg = message || "Expected #{expected.inspect}, got #{actual.inspect}"
33
+ raise msg unless expected == actual
34
+ end
35
+
36
+ def assert_kind_of(klass, obj)
37
+ raise "Expected #{klass}, got #{obj.class}" unless obj.is_a?(klass)
38
+ end
39
+
40
+ def assert_includes(collection, item)
41
+ raise "Expected #{collection.inspect} to include #{item.inspect}" unless collection.include?(item)
42
+ end
43
+
44
+ def assert_nil(obj)
45
+ raise "Expected nil, got #{obj.inspect}" unless obj.nil?
46
+ end
47
+
48
+ def refute(condition, message = 'Expected condition to be false')
49
+ raise message if condition
50
+ end
51
+
52
+ def refute_nil(obj, message = 'Expected non-nil value')
53
+ raise message if obj.nil?
54
+ end
55
+
56
+ def refute_equal(expected, actual)
57
+ raise "Expected #{actual.inspect} to not equal #{expected.inspect}" if expected == actual
58
+ end
59
+
60
+ # Refute helper (alias for refute)
61
+ def refute(condition, message = 'Expected condition to be false')
62
+ raise message if condition
63
+ end
64
+
65
+ # Assert with message
66
+ def assert_with_message(condition, message = 'Assertion failed')
67
+ raise message unless condition
68
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'rails/generators/migration'
5
+
6
+ module Trakable
7
+ module Generators
8
+ class InstallGenerator < Rails::Generators::Base
9
+ include Rails::Generators::Migration
10
+
11
+ source_root File.expand_path('templates', __dir__)
12
+
13
+ desc 'Creates a migration and initializer for Trakable.'
14
+
15
+ def self.next_migration_number(_dir)
16
+ Time.now.utc.strftime('%Y%m%d%H%M%S')
17
+ end
18
+
19
+ def copy_migration
20
+ migration_template 'create_traks_migration.rb', 'db/migrate/create_traks.rb'
21
+ end
22
+
23
+ def copy_initializer
24
+ template 'trakable_initializer.rb', 'config/initializers/trakable.rb'
25
+ end
26
+ end
27
+ end
28
+ end