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.
- checksums.yaml +7 -0
- data/.rubocop.yml +81 -0
- data/CHANGELOG.md +50 -0
- data/LICENSE +21 -0
- data/README.md +330 -0
- data/Rakefile +16 -0
- data/benchmark/full_benchmark.rb +221 -0
- data/benchmark/integration_memory.rb +70 -0
- data/benchmark/memory_benchmark.rb +141 -0
- data/benchmark/perf_benchmark.rb +130 -0
- data/integration/README.md +65 -0
- data/integration/run_all.rb +62 -0
- data/integration/scenarios/01-basic-tracking/scenario.rb +51 -0
- data/integration/scenarios/02-revert-restoration/scenario.rb +103 -0
- data/integration/scenarios/03-whodunnit-tracking/scenario.rb +72 -0
- data/integration/scenarios/04-cleanup-retention/scenario.rb +66 -0
- data/integration/scenarios/05-without-tracking/scenario.rb +62 -0
- data/integration/scenarios/06-callback-lifecycle/scenario.rb +103 -0
- data/integration/scenarios/07-global-config/scenario.rb +52 -0
- data/integration/scenarios/08-controller-integration/scenario.rb +44 -0
- data/integration/scenarios/09-cleanup-max-traks/scenario.rb +58 -0
- data/integration/scenarios/10-model-configuration/scenario.rb +68 -0
- data/integration/scenarios/11-conditional-tracking/scenario.rb +48 -0
- data/integration/scenarios/12-metadata/scenario.rb +54 -0
- data/integration/scenarios/13-traks-association/scenario.rb +80 -0
- data/integration/scenarios/14-time-travel/scenario.rb +132 -0
- data/integration/scenarios/15-diffing-changeset/scenario.rb +109 -0
- data/integration/scenarios/16-serialization/scenario.rb +159 -0
- data/integration/scenarios/17-associations-tracking/scenario.rb +143 -0
- data/integration/scenarios/18-bulk-operations/scenario.rb +70 -0
- data/integration/scenarios/19-transactions/scenario.rb +89 -0
- data/integration/scenarios/20-performance/scenario.rb +89 -0
- data/integration/scenarios/21-storage-backends/scenario.rb +52 -0
- data/integration/scenarios/22-multi-tenancy/scenario.rb +49 -0
- data/integration/scenarios/23-sti/scenario.rb +58 -0
- data/integration/scenarios/24-edge-cases-part1/scenario.rb +86 -0
- data/integration/scenarios/25-edge-cases-part2/scenario.rb +74 -0
- data/integration/scenarios/26-edge-cases-part3/scenario.rb +76 -0
- data/integration/scenarios/27-api-query-interface/scenario.rb +78 -0
- data/integration/scenarios/28-security-compliance/scenario.rb +61 -0
- data/integration/scenarios/29-soft-delete/scenario.rb +43 -0
- data/integration/scenarios/30-custom-events/scenario.rb +45 -0
- data/integration/scenarios/31-gem-packaging/scenario.rb +58 -0
- data/integration/scenarios/32-bypass-fail-closed/scenario.rb +77 -0
- data/integration/scenarios/33-coexistence-standalone/scenario.rb +53 -0
- data/integration/scenarios/34-real-tracking/scenario.rb +254 -0
- data/integration/scenarios/35-revert-undo/scenario.rb +235 -0
- data/integration/scenarios/36-whodunnit-deep/scenario.rb +281 -0
- data/integration/scenarios/37-real-world-use-cases/scenario.rb +1213 -0
- data/integration/scenarios/38-concurrency/scenario.rb +163 -0
- data/integration/scenarios/39-query-scopes/scenario.rb +126 -0
- data/integration/scenarios/40-whodunnit-config/scenario.rb +113 -0
- data/integration/scenarios/41-batch-cleanup/scenario.rb +186 -0
- data/integration/scenarios/scenario_runner.rb +68 -0
- data/lib/generators/trakable/install_generator.rb +28 -0
- data/lib/generators/trakable/templates/create_traks_migration.rb +23 -0
- data/lib/generators/trakable/templates/trakable_initializer.rb +15 -0
- data/lib/trakable/cleanup.rb +89 -0
- data/lib/trakable/config.rb +22 -0
- data/lib/trakable/context.rb +85 -0
- data/lib/trakable/controller.rb +25 -0
- data/lib/trakable/model.rb +99 -0
- data/lib/trakable/railtie.rb +28 -0
- data/lib/trakable/revertable.rb +166 -0
- data/lib/trakable/tracker.rb +134 -0
- data/lib/trakable/trak.rb +98 -0
- data/lib/trakable/version.rb +5 -0
- data/lib/trakable.rb +51 -0
- data/trakable.gemspec +41 -0
- 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
|