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,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