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,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Scenario 34: Real Tracking Tests
4
+ # Tests with actual state changes and verification
5
+
6
+ require_relative '../scenario_runner'
7
+
8
+ # Simple in-memory storage for testing
9
+ class TrakStore
10
+ class << self
11
+ def storage
12
+ @storage ||= []
13
+ end
14
+
15
+ def <<(trak)
16
+ storage << trak
17
+ end
18
+
19
+ def all
20
+ storage
21
+ end
22
+
23
+ def for_item(type, id)
24
+ storage.select { |t| t.item_type == type && t.item_id == id }
25
+ end
26
+
27
+ def clear
28
+ @storage = []
29
+ end
30
+ end
31
+ end
32
+
33
+ # Simple trak object
34
+ class SimpleTrak
35
+ attr_accessor :item_type, :item_id, :event, :object, :changeset, :whodunnit, :created_at
36
+
37
+ def initialize(attrs = {})
38
+ attrs.each { |k, v| send("#{k}=", v) if respond_to?("#{k}=") }
39
+ @created_at ||= Time.now
40
+ end
41
+ end
42
+
43
+ # Mock model that actually simulates tracking
44
+ class TrackedPost
45
+ attr_accessor :id, :title, :body, :views
46
+
47
+ def initialize(id: nil, title: '', body: '', views: 0)
48
+ @id = id
49
+ @title = title
50
+ @body = body
51
+ @views = views
52
+ end
53
+
54
+ def attributes
55
+ { 'id' => @id, 'title' => @title, 'body' => @body, 'views' => @views }
56
+ end
57
+
58
+ def simulate_create(user: nil)
59
+ SimpleTrak.new(
60
+ item_type: 'TrackedPost',
61
+ item_id: @id,
62
+ event: 'create',
63
+ object: nil,
64
+ changeset: attributes.except('id'),
65
+ whodunnit: user
66
+ )
67
+ end
68
+
69
+ def simulate_update(old_attrs, user: nil)
70
+ changeset = {}
71
+ attributes.each do |k, v|
72
+ changeset[k] = [old_attrs[k], v] if old_attrs[k] != v
73
+ end
74
+
75
+ SimpleTrak.new(
76
+ item_type: 'TrackedPost',
77
+ item_id: @id,
78
+ event: 'update',
79
+ object: old_attrs,
80
+ changeset: changeset,
81
+ whodunnit: user
82
+ )
83
+ end
84
+
85
+ def simulate_destroy(user: nil)
86
+ SimpleTrak.new(
87
+ item_type: 'TrackedPost',
88
+ item_id: @id,
89
+ event: 'destroy',
90
+ object: attributes.except('id'),
91
+ changeset: {},
92
+ whodunnit: user
93
+ )
94
+ end
95
+ end
96
+
97
+ # Mock user
98
+ class TestUser
99
+ attr_reader :id, :name
100
+
101
+ def initialize(id, name)
102
+ @id = id
103
+ @name = name
104
+ end
105
+ end
106
+
107
+ run_scenario 'Real Tracking Tests' do
108
+ puts '=== TEST 1: Create tracking ==='
109
+
110
+ post = TrackedPost.new(id: 1, title: 'Hello', body: 'World')
111
+ trak = post.simulate_create
112
+ TrakStore << trak
113
+
114
+ assert_equal 'create', trak.event
115
+ assert_equal 'TrackedPost', trak.item_type
116
+ assert_equal 1, trak.item_id
117
+ assert_equal({ 'title' => 'Hello', 'body' => 'World', 'views' => 0 }, trak.changeset)
118
+ puts ' ✓ Create event tracked with correct data'
119
+
120
+ puts '=== TEST 2: Update tracking with diff ==='
121
+
122
+ old_attrs = post.attributes.dup
123
+ post.title = 'Hello Updated'
124
+ post.views = 10
125
+
126
+ trak = post.simulate_update(old_attrs)
127
+ TrakStore << trak
128
+ assert_equal 'update', trak.event
129
+ assert_equal 'Hello', trak.object['title']
130
+ assert_equal ['Hello', 'Hello Updated'], trak.changeset['title']
131
+ assert_equal [0, 10], trak.changeset['views']
132
+ puts ' ✓ Update event tracked with correct diff'
133
+
134
+ puts '=== TEST 3: Destroy tracking ==='
135
+
136
+ trak = post.simulate_destroy
137
+ TrakStore << trak
138
+ assert_equal 'destroy', trak.event
139
+ assert_equal 'Hello Updated', trak.object['title']
140
+ puts ' ✓ Destroy event tracked with final state'
141
+
142
+ puts '=== TEST 4: Whodunnit tracking ==='
143
+
144
+ TrakStore.clear
145
+ alice = TestUser.new(1, 'Alice')
146
+
147
+ post2 = TrackedPost.new(id: 2, title: 'By Alice')
148
+ trak = post2.simulate_create(user: alice)
149
+ TrakStore << trak
150
+
151
+ assert_equal alice, trak.whodunnit
152
+ puts ' ✓ Whodunnit correctly stored'
153
+
154
+ puts '=== TEST 5: Trak history query ==='
155
+
156
+ all_traks = TrakStore.for_item('TrackedPost', 2)
157
+ assert_equal 1, all_traks.length
158
+ assert_equal 'create', all_traks.first.event
159
+ puts ' ✓ Can query history for an item'
160
+
161
+ puts '=== TEST 6: Fuzzy test - multiple rapid updates ==='
162
+
163
+ TrakStore.clear
164
+ post3 = TrackedPost.new(id: 3, title: 'Initial')
165
+
166
+ 100.times do |i|
167
+ old_attrs = post3.attributes.dup
168
+ post3.title = "Title #{i}"
169
+ trak = post3.simulate_update(old_attrs)
170
+ TrakStore << trak
171
+ end
172
+
173
+ history = TrakStore.for_item('TrackedPost', 3)
174
+ assert_equal 100, history.length
175
+ puts ' ✓ 100 rapid updates all tracked correctly'
176
+
177
+ puts '=== TEST 7: Fuzzy test - concurrent-like updates ==='
178
+
179
+ # Clear once before starting threads
180
+ TrakStore.clear
181
+ threads = []
182
+ mutex = Mutex.new
183
+
184
+ 10.times do |i|
185
+ threads << Thread.new do
186
+ post = TrackedPost.new(id: 100 + i, title: "Thread #{i}")
187
+ trak = post.simulate_create
188
+ mutex.synchronize { TrakStore << trak }
189
+ end
190
+ end
191
+
192
+ threads.each(&:join)
193
+ assert_equal 10, TrakStore.all.length
194
+ puts ' ✓ Thread-safe tracking works'
195
+
196
+ puts '=== TEST 8: Fuzzy test - empty vs nil vs blank ==='
197
+
198
+ TrakStore.clear
199
+ post4 = TrackedPost.new(id: 4, title: '', body: nil)
200
+
201
+ old_attrs = { 'id' => 4, 'title' => nil, 'body' => '', 'views' => 0 }
202
+ new_attrs = post4.attributes
203
+
204
+ changeset = {}
205
+ new_attrs.each do |k, v|
206
+ changeset[k] = [old_attrs[k], v] if old_attrs[k] != v
207
+ end
208
+
209
+ assert changeset.key?('title'), 'nil to empty string should be tracked'
210
+ assert changeset.key?('body'), 'empty string to nil should be tracked'
211
+ puts ' ✓ Empty string vs nil distinction preserved'
212
+
213
+ puts '=== TEST 9: Fuzzy test - special characters ==='
214
+
215
+ TrakStore.clear
216
+ special_chars = [
217
+ '<script>alert("xss")</script>',
218
+ "O'Reilly",
219
+ 'Hello"World',
220
+ "Line1\nLine2\tTab",
221
+ '日本語',
222
+ '🎉',
223
+ '../../../etc/passwd'
224
+ ]
225
+
226
+ special_chars.each_with_index do |title, i|
227
+ post = TrackedPost.new(id: 200 + i, title: title)
228
+ trak = post.simulate_create
229
+ assert_equal title, trak.changeset['title']
230
+ end
231
+
232
+ puts ' ✓ Special characters preserved correctly'
233
+
234
+ puts '=== TEST 10: Revert to previous state ==='
235
+
236
+ TrakStore.clear
237
+ post5 = TrackedPost.new(id: 5, title: 'Original')
238
+ trak1 = post5.simulate_create
239
+ TrakStore << trak1
240
+
241
+ old_attrs = post5.attributes.dup
242
+ post5.title = 'Updated'
243
+ trak2 = post5.simulate_update(old_attrs)
244
+ TrakStore << trak2
245
+
246
+ # Get previous state from the update trak
247
+ update_trak = TrakStore.all.last
248
+ previous_state = update_trak.object
249
+
250
+ assert_equal 'Original', previous_state['title']
251
+ puts ' ✓ Can retrieve previous state for revert'
252
+
253
+ puts "\n=== Scenario 34: Real Tracking Tests PASSED ✓ ==="
254
+ end
@@ -0,0 +1,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Scenario 35: Revert / Undo Tests
4
+ # Tests going back to previous states
5
+
6
+ require_relative '../scenario_runner'
7
+
8
+ # Simulated state storage
9
+ class StateHistory
10
+ class << self
11
+ def states
12
+ @states ||= []
13
+ end
14
+
15
+ def push(state)
16
+ states << { state: state.dup, at: Time.now }
17
+ end
18
+
19
+ def current
20
+ states.last&.dig(:state) || {}
21
+ end
22
+
23
+ def at(index)
24
+ states[index]&.dig(:state)
25
+ end
26
+
27
+ def rollback_to(index)
28
+ @states = states[0..index]
29
+ current
30
+ end
31
+
32
+ def clear
33
+ @states = []
34
+ end
35
+ end
36
+ end
37
+
38
+ # Simulated tracked model
39
+ class RevertiblePost
40
+ attr_accessor :id, :title, :body, :status, :views
41
+
42
+ def initialize(id: nil, title: '', body: '', status: 'draft', views: 0)
43
+ @id = id
44
+ @title = title
45
+ @body = body
46
+ @status = status
47
+ @views = views
48
+ StateHistory.push(attributes)
49
+ end
50
+
51
+ def attributes
52
+ { 'id' => @id, 'title' => @title, 'body' => @body, 'status' => @status, 'views' => @views }
53
+ end
54
+
55
+ def update!(new_attrs)
56
+ new_attrs.each { |k, v| send("#{k}=", v) if respond_to?("#{k}=") }
57
+ StateHistory.push(attributes)
58
+ end
59
+
60
+ def destroy
61
+ StateHistory.push(attributes.merge('_destroyed' => true))
62
+ end
63
+
64
+ # Go back to a previous state
65
+ def revert_to!(state_index)
66
+ previous = StateHistory.at(state_index)
67
+ return nil unless previous
68
+
69
+ previous.each { |k, v| send("#{k}=", v) if respond_to?("#{k}=") }
70
+ StateHistory.push(attributes)
71
+ self
72
+ end
73
+
74
+ # Undo last change
75
+ def undo!
76
+ return nil if StateHistory.states.length < 2
77
+
78
+ StateHistory.states.pop # Remove current
79
+ previous = StateHistory.current
80
+ previous.each { |k, v| send("#{k}=", v) if respond_to?("#{k}=") }
81
+ self
82
+ end
83
+
84
+ def history
85
+ StateHistory.states.map { |s| s[:state] }
86
+ end
87
+
88
+ def destroyed?
89
+ StateHistory.current['_destroyed'] == true
90
+ end
91
+ end
92
+
93
+ run_scenario 'Revert / Undo Tests' do
94
+ puts '=== TEST 1: Basic undo (go back one step) ==='
95
+
96
+ StateHistory.clear
97
+ post = RevertiblePost.new(id: 1, title: 'Version 1')
98
+
99
+ post.update!('title' => 'Version 2')
100
+ post.update!('title' => 'Version 3')
101
+
102
+ assert_equal 'Version 3', post.title
103
+ assert_equal 3, post.history.length
104
+
105
+ post.undo!
106
+ assert_equal 'Version 2', post.title
107
+ puts ' ✓ Undo goes back one step'
108
+
109
+ puts '=== TEST 2: Revert to specific version ==='
110
+
111
+ StateHistory.clear
112
+ post = RevertiblePost.new(id: 2, title: 'Original', status: 'draft')
113
+
114
+ post.update!('title' => 'Edited', 'status' => 'review')
115
+ post.update!('title' => 'Final', 'status' => 'published')
116
+ post.update!('views' => 100)
117
+
118
+ # Revert to version 2 (index 1)
119
+ post.revert_to!(1)
120
+ assert_equal 'Edited', post.title
121
+ assert_equal 'review', post.status
122
+ puts ' ✓ Revert to specific version works'
123
+
124
+ puts '=== TEST 3: Revert create = destroy ==='
125
+
126
+ StateHistory.clear
127
+ post = RevertiblePost.new(id: 3, title: 'To be destroyed')
128
+
129
+ post.destroy
130
+ assert post.destroyed?
131
+ puts ' ✓ Destroy creates a destroyed state'
132
+
133
+ puts '=== TEST 4: Revert destroy = restore ==='
134
+
135
+ StateHistory.clear
136
+ post = RevertiblePost.new(id: 4, title: 'Will be restored')
137
+
138
+ post.update!('title' => 'Updated')
139
+ post.destroy
140
+
141
+ # Revert to before destroy
142
+ post.revert_to!(1) # Index 1 = "Updated" state
143
+ refute post.destroyed?
144
+ assert_equal 'Updated', post.title
145
+ puts ' ✓ Revert from destroy restores record'
146
+
147
+ puts '=== TEST 5: Multiple undos in sequence ==='
148
+
149
+ StateHistory.clear
150
+ post = RevertiblePost.new(id: 5, title: 'Step 0')
151
+
152
+ post.update!('title' => 'Step 1')
153
+ post.update!('title' => 'Step 2')
154
+ post.update!('title' => 'Step 3')
155
+ post.update!('title' => 'Step 4')
156
+
157
+ 4.times { post.undo! }
158
+ assert_equal 'Step 0', post.title
159
+ puts ' ✓ Multiple undos work correctly'
160
+
161
+ puts '=== TEST 6: Fuzzy - Random updates then verify history ==='
162
+
163
+ StateHistory.clear
164
+ post = RevertiblePost.new(id: 6, title: 'Start')
165
+
166
+ titles = ['Start']
167
+ 50.times do
168
+ new_title = "Title #{rand(1000)}"
169
+ titles << new_title
170
+ post.update!('title' => new_title)
171
+ end
172
+
173
+ # Verify history
174
+ post.history.each_with_index do |state, i|
175
+ assert_equal titles[i], state['title'], "History mismatch at index #{i}"
176
+ end
177
+ puts ' ✓ 50 random updates preserved correctly in history'
178
+
179
+ puts '=== TEST 7: Fuzzy - Revert to random points ==='
180
+
181
+ 10.times do
182
+ StateHistory.clear
183
+ post = RevertiblePost.new(id: 7, title: 'A', views: 0)
184
+
185
+ # Create some history - state[i] will have views = i
186
+ # State 0: views=0 (initial)
187
+ # State 1: views=1 (after update with i=1)
188
+ # ...
189
+ 20.times { |i| post.update!('views' => i + 1) }
190
+
191
+ # Revert to random point
192
+ target = rand(0..20)
193
+ post.revert_to!(target)
194
+ assert_equal target, post.views
195
+ end
196
+ puts ' ✓ Random reverts work correctly (10 iterations)'
197
+
198
+ puts '=== TEST 8: Edge case - Revert to non-existent version ==='
199
+
200
+ StateHistory.clear
201
+ post = RevertiblePost.new(id: 9, title: 'Only')
202
+
203
+ result = post.revert_to!(999)
204
+ assert_nil result
205
+ puts ' ✓ Revert to non-existent version returns nil'
206
+
207
+ puts '=== TEST 9: Edge case - Undo on fresh record ==='
208
+
209
+ StateHistory.clear
210
+ post = RevertiblePost.new(id: 10, title: 'Fresh')
211
+
212
+ result = post.undo! # Only 1 state, can't undo
213
+ assert_equal 'Fresh', post.title # Should stay the same
214
+ puts ' ✓ Undo on fresh record handles gracefully'
215
+
216
+ puts '=== TEST 10: Chain of custody ==='
217
+
218
+ StateHistory.clear
219
+ post = RevertiblePost.new(id: 11, title: 'Chain')
220
+
221
+ post.update!('title' => 'Link 1')
222
+ post.update!('title' => 'Link 2')
223
+ post.update!('title' => 'Link 3')
224
+
225
+ # Verify we can trace back through all states
226
+ history = post.history
227
+ assert_equal 4, history.length
228
+ assert_equal 'Chain', history[0]['title']
229
+ assert_equal 'Link 1', history[1]['title']
230
+ assert_equal 'Link 2', history[2]['title']
231
+ assert_equal 'Link 3', history[3]['title']
232
+ puts ' ✓ Complete chain of custody available'
233
+
234
+ puts "\n=== Scenario 35: Revert / Undo Tests PASSED ✓ ==="
235
+ end