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