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,281 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Scenario 36: Whodunnit Deep Tests
|
|
4
|
+
# Tests who made the changes with various scenarios
|
|
5
|
+
|
|
6
|
+
require_relative '../scenario_runner'
|
|
7
|
+
|
|
8
|
+
# Simulated actor (user, admin, api key, etc.)
|
|
9
|
+
class Actor
|
|
10
|
+
attr_reader :id, :type, :name
|
|
11
|
+
|
|
12
|
+
def initialize(id, type, name)
|
|
13
|
+
@id = id
|
|
14
|
+
@type = type
|
|
15
|
+
@name = name
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_s
|
|
19
|
+
"#{type}##{id}(#{name})"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def ==(other)
|
|
23
|
+
other.is_a?(Actor) && @id == other.id && @type == other.type
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Simulated trak with whodunnit
|
|
28
|
+
class WhodunnitTrak
|
|
29
|
+
attr_reader :event, :whodunnit_type, :whodunnit_id, :timestamp, :changeset
|
|
30
|
+
|
|
31
|
+
def initialize(event:, whodunnit:, changeset:, timestamp: Time.now)
|
|
32
|
+
@event = event
|
|
33
|
+
@whodunnit_type = whodunnit&.type
|
|
34
|
+
@whodunnit_id = whodunnit&.id
|
|
35
|
+
@changeset = changeset
|
|
36
|
+
@timestamp = timestamp
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def whodunnit
|
|
40
|
+
return nil unless @whodunnit_type && @whodunnit_id
|
|
41
|
+
|
|
42
|
+
# Simulate finding the actor
|
|
43
|
+
Actor.new(@whodunnit_id, @whodunnit_type, "Found #{@whodunnit_type}")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def anonymous?
|
|
47
|
+
@whodunnit_type.nil? || @whodunnit_id.nil?
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Simulated tracked item with whodunnit
|
|
52
|
+
class WhodunnitPost
|
|
53
|
+
attr_accessor :id, :title, :traks
|
|
54
|
+
|
|
55
|
+
def initialize(id:, title:)
|
|
56
|
+
@id = id
|
|
57
|
+
@title = title
|
|
58
|
+
@traks = []
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def update!(new_title, actor:)
|
|
62
|
+
old_title = @title
|
|
63
|
+
@title = new_title
|
|
64
|
+
|
|
65
|
+
trak = WhodunnitTrak.new(
|
|
66
|
+
event: 'update',
|
|
67
|
+
whodunnit: actor,
|
|
68
|
+
changeset: { 'title' => [old_title, new_title] }
|
|
69
|
+
)
|
|
70
|
+
@traks << trak
|
|
71
|
+
trak
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def create!(actor:)
|
|
75
|
+
trak = WhodunnitTrak.new(
|
|
76
|
+
event: 'create',
|
|
77
|
+
whodunnit: actor,
|
|
78
|
+
changeset: { 'title' => [@title] }
|
|
79
|
+
)
|
|
80
|
+
@traks << trak
|
|
81
|
+
trak
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def destroy!(actor:)
|
|
85
|
+
trak = WhodunnitTrak.new(
|
|
86
|
+
event: 'destroy',
|
|
87
|
+
whodunnit: actor,
|
|
88
|
+
changeset: {}
|
|
89
|
+
)
|
|
90
|
+
@traks << trak
|
|
91
|
+
trak
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def changes_by(actor_type)
|
|
95
|
+
@traks.select { |t| t.whodunnit_type == actor_type.to_s }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def changes_by_id(actor_id)
|
|
99
|
+
@traks.select { |t| t.whodunnit_id == actor_id }
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
run_scenario 'Whodunnit Deep Tests' do
|
|
104
|
+
puts '=== TEST 1: Basic whodunnit - User makes a change ==='
|
|
105
|
+
|
|
106
|
+
alice = Actor.new(1, 'User', 'Alice')
|
|
107
|
+
post = WhodunnitPost.new(id: 1, title: 'Hello')
|
|
108
|
+
post.create!(actor: alice)
|
|
109
|
+
|
|
110
|
+
trak = post.traks.last
|
|
111
|
+
assert_equal 'User', trak.whodunnit_type
|
|
112
|
+
assert_equal 1, trak.whodunnit_id
|
|
113
|
+
puts ' ✓ User tracked as whodunnit'
|
|
114
|
+
|
|
115
|
+
puts '=== TEST 2: Polymorphic whodunnit - Different actor types ==='
|
|
116
|
+
|
|
117
|
+
bob = Actor.new(2, 'User', 'Bob')
|
|
118
|
+
admin = Actor.new(1, 'Admin', 'SuperAdmin')
|
|
119
|
+
api_key = Actor.new(99, 'ApiKey', 'system-key')
|
|
120
|
+
|
|
121
|
+
post = WhodunnitPost.new(id: 2, title: 'Initial')
|
|
122
|
+
post.create!(actor: bob)
|
|
123
|
+
post.update!('Updated by admin', actor: admin)
|
|
124
|
+
post.update!('Updated by API', actor: api_key)
|
|
125
|
+
|
|
126
|
+
assert_equal 3, post.traks.length
|
|
127
|
+
assert_equal 'User', post.traks[0].whodunnit_type
|
|
128
|
+
assert_equal 'Admin', post.traks[1].whodunnit_type
|
|
129
|
+
assert_equal 'ApiKey', post.traks[2].whodunnit_type
|
|
130
|
+
puts ' ✓ Polymorphic whodunnit tracked correctly'
|
|
131
|
+
|
|
132
|
+
puts '=== TEST 3: Anonymous changes (no whodunnit) ==='
|
|
133
|
+
|
|
134
|
+
post = WhodunnitPost.new(id: 3, title: 'Anonymous')
|
|
135
|
+
post.create!(actor: nil)
|
|
136
|
+
|
|
137
|
+
trak = post.traks.last
|
|
138
|
+
assert trak.anonymous?
|
|
139
|
+
assert_nil trak.whodunnit_type
|
|
140
|
+
assert_nil trak.whodunnit_id
|
|
141
|
+
puts ' ✓ Anonymous changes tracked with nil whodunnit'
|
|
142
|
+
|
|
143
|
+
puts '=== TEST 4: Query changes by actor type ==='
|
|
144
|
+
|
|
145
|
+
post = WhodunnitPost.new(id: 4, title: 'Start')
|
|
146
|
+
post.create!(actor: Actor.new(1, 'User', 'U1'))
|
|
147
|
+
post.update!('V1', actor: Actor.new(1, 'Admin', 'A1'))
|
|
148
|
+
post.update!('V2', actor: Actor.new(2, 'User', 'U2'))
|
|
149
|
+
post.update!('V3', actor: Actor.new(1, 'Admin', 'A1'))
|
|
150
|
+
|
|
151
|
+
user_changes = post.changes_by('User')
|
|
152
|
+
admin_changes = post.changes_by('Admin')
|
|
153
|
+
|
|
154
|
+
assert_equal 2, user_changes.length
|
|
155
|
+
assert_equal 2, admin_changes.length
|
|
156
|
+
puts ' ✓ Can query changes by actor type'
|
|
157
|
+
|
|
158
|
+
puts '=== TEST 5: Query changes by specific actor ID ==='
|
|
159
|
+
|
|
160
|
+
alice = Actor.new(1, 'User', 'Alice')
|
|
161
|
+
bob = Actor.new(2, 'User', 'Bob')
|
|
162
|
+
|
|
163
|
+
post = WhodunnitPost.new(id: 5, title: 'Start')
|
|
164
|
+
post.create!(actor: alice)
|
|
165
|
+
post.update!('By Alice', actor: alice)
|
|
166
|
+
post.update!('By Bob', actor: bob)
|
|
167
|
+
post.update!('By Alice again', actor: alice)
|
|
168
|
+
|
|
169
|
+
alice_changes = post.changes_by_id(1)
|
|
170
|
+
bob_changes = post.changes_by_id(2)
|
|
171
|
+
|
|
172
|
+
assert_equal 3, alice_changes.length
|
|
173
|
+
assert_equal 1, bob_changes.length
|
|
174
|
+
puts ' ✓ Can query changes by specific actor ID'
|
|
175
|
+
|
|
176
|
+
puts '=== TEST 6: Fuzzy - Many users editing same record ==='
|
|
177
|
+
|
|
178
|
+
users = 20.times.map { |i| Actor.new(i + 1, 'User', "User#{i + 1}") }
|
|
179
|
+
post = WhodunnitPost.new(id: 6, title: 'Collaborative')
|
|
180
|
+
post.create!(actor: users.first) # Initial create
|
|
181
|
+
|
|
182
|
+
100.times do
|
|
183
|
+
random_user = users.sample
|
|
184
|
+
post.update!("Edit by #{random_user.name}", actor: random_user)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
assert_equal 101, post.traks.length # create + 100 updates
|
|
188
|
+
|
|
189
|
+
# Count edits per user
|
|
190
|
+
edits_per_user = Hash.new(0)
|
|
191
|
+
post.traks.each do |t|
|
|
192
|
+
edits_per_user[t.whodunnit_id] += 1 if t.whodunnit_id
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
total_edits = edits_per_user.values.sum
|
|
196
|
+
assert_equal 101, total_edits
|
|
197
|
+
puts ' ✓ 100 random edits by 20 users tracked correctly'
|
|
198
|
+
|
|
199
|
+
puts '=== TEST 7: Fuzzy - Actor type distribution ==='
|
|
200
|
+
|
|
201
|
+
post = WhodunnitPost.new(id: 7, title: 'Stats')
|
|
202
|
+
actor_types = %w[User Admin System ApiKey]
|
|
203
|
+
|
|
204
|
+
1000.times do
|
|
205
|
+
type = actor_types.sample
|
|
206
|
+
id = rand(1..10)
|
|
207
|
+
actor = Actor.new(id, type, "#{type}##{id}")
|
|
208
|
+
post.update!("Edit", actor: actor)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Verify distribution
|
|
212
|
+
type_counts = Hash.new(0)
|
|
213
|
+
post.traks.each { |t| type_counts[t.whodunnit_type] += 1 }
|
|
214
|
+
|
|
215
|
+
# Each type should have some edits (very likely with 1000 samples)
|
|
216
|
+
actor_types.each do |type|
|
|
217
|
+
assert type_counts[type] > 0, "#{type} should have at least one edit"
|
|
218
|
+
end
|
|
219
|
+
puts ' ✓ 1000 edits distributed across actor types'
|
|
220
|
+
|
|
221
|
+
puts '=== TEST 8: Thread safety - Concurrent actor contexts ==='
|
|
222
|
+
|
|
223
|
+
results = []
|
|
224
|
+
threads = []
|
|
225
|
+
|
|
226
|
+
10.times do |i|
|
|
227
|
+
threads << Thread.new do
|
|
228
|
+
user = Actor.new(i, 'User', "Thread#{i}")
|
|
229
|
+
post = WhodunnitPost.new(id: 100 + i, title: "Thread Post #{i}")
|
|
230
|
+
trak = post.create!(actor: user)
|
|
231
|
+
results << [i, trak.whodunnit_id, trak.whodunnit_type]
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
threads.each(&:join)
|
|
236
|
+
|
|
237
|
+
results.each do |thread_id, whodunnit_id, whodunnit_type|
|
|
238
|
+
assert_equal thread_id, whodunnit_id
|
|
239
|
+
assert_equal 'User', whodunnit_type
|
|
240
|
+
end
|
|
241
|
+
puts ' ✓ Thread-safe whodunnit tracking works'
|
|
242
|
+
|
|
243
|
+
puts '=== TEST 9: Whodunnit chain of custody ==='
|
|
244
|
+
|
|
245
|
+
alice = Actor.new(1, 'User', 'Alice')
|
|
246
|
+
bob = Actor.new(2, 'User', 'Bob')
|
|
247
|
+
admin = Actor.new(1, 'Admin', 'Admin')
|
|
248
|
+
|
|
249
|
+
post = WhodunnitPost.new(id: 8, title: 'Chain')
|
|
250
|
+
post.create!(actor: alice)
|
|
251
|
+
post.update!('Edit 1', actor: bob)
|
|
252
|
+
post.update!('Edit 2', actor: admin)
|
|
253
|
+
post.destroy!(actor: alice)
|
|
254
|
+
|
|
255
|
+
# Verify chain
|
|
256
|
+
assert_equal 1, post.traks[0].whodunnit_id
|
|
257
|
+
assert_equal 2, post.traks[1].whodunnit_id
|
|
258
|
+
assert_equal 1, post.traks[2].whodunnit_id
|
|
259
|
+
assert_equal 1, post.traks[3].whodunnit_id
|
|
260
|
+
puts ' ✓ Complete chain of custody tracked'
|
|
261
|
+
|
|
262
|
+
puts '=== TEST 10: Who made the most edits? ==='
|
|
263
|
+
|
|
264
|
+
post = WhodunnitPost.new(id: 9, title: 'Competition')
|
|
265
|
+
users = 5.times.map { |i| Actor.new(i + 1, 'User', "User#{i + 1}") }
|
|
266
|
+
|
|
267
|
+
50.times do
|
|
268
|
+
user = users.sample
|
|
269
|
+
post.update!("Edit", actor: user)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Count edits per user
|
|
273
|
+
edits_per_user = Hash.new(0)
|
|
274
|
+
post.traks.each { |t| edits_per_user[t.whodunnit_id] += 1 if t.whodunnit_id }
|
|
275
|
+
|
|
276
|
+
top_editor = edits_per_user.max_by { |_, count| count }
|
|
277
|
+
refute_nil top_editor
|
|
278
|
+
puts " ✓ Top editor is User##{top_editor[0]} with #{top_editor[1]} edits"
|
|
279
|
+
|
|
280
|
+
puts "\n=== Scenario 36: Whodunnit Deep Tests PASSED ✓ ==="
|
|
281
|
+
end
|