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,1213 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Scenario 37: Real-World Use Cases
|
|
4
|
+
# Tests realistic tracking scenarios across different domains
|
|
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 for_type(type)
|
|
28
|
+
storage.select { |t| t.item_type == type }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def clear
|
|
32
|
+
@storage = []
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Simple trak object
|
|
38
|
+
class SimpleTrak
|
|
39
|
+
attr_accessor :item_type, :item_id, :event, :object, :changeset, :whodunnit, :metadata, :created_at
|
|
40
|
+
|
|
41
|
+
def initialize(attrs = {})
|
|
42
|
+
attrs.each { |k, v| send("#{k}=", v) if respond_to?("#{k}=") }
|
|
43
|
+
@created_at ||= Time.now
|
|
44
|
+
@metadata ||= {}
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# ============================================================================
|
|
49
|
+
# 1. Blog System
|
|
50
|
+
# ============================================================================
|
|
51
|
+
|
|
52
|
+
class BlogPost
|
|
53
|
+
attr_accessor :id, :title, :body, :status, :author_id, :view_count, :tag_ids, :traks
|
|
54
|
+
|
|
55
|
+
STATUSES = %w[draft published archived].freeze
|
|
56
|
+
|
|
57
|
+
def initialize(id:, title:, body:, author_id:, status: 'draft')
|
|
58
|
+
@id = id
|
|
59
|
+
@title = title
|
|
60
|
+
@body = body
|
|
61
|
+
@author_id = author_id
|
|
62
|
+
@status = status
|
|
63
|
+
@view_count = 0
|
|
64
|
+
@tag_ids = []
|
|
65
|
+
@traks = []
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def attributes
|
|
69
|
+
{
|
|
70
|
+
'id' => @id,
|
|
71
|
+
'title' => @title,
|
|
72
|
+
'body' => @body,
|
|
73
|
+
'status' => @status,
|
|
74
|
+
'author_id' => @author_id,
|
|
75
|
+
'view_count' => @view_count,
|
|
76
|
+
'tag_ids' => @tag_ids.dup
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def track_event(event, changeset, whodunnit: nil, metadata: {})
|
|
81
|
+
trak = SimpleTrak.new(
|
|
82
|
+
item_type: 'BlogPost',
|
|
83
|
+
item_id: @id,
|
|
84
|
+
event: event,
|
|
85
|
+
object: attributes,
|
|
86
|
+
changeset: changeset,
|
|
87
|
+
whodunnit: whodunnit,
|
|
88
|
+
metadata: metadata
|
|
89
|
+
)
|
|
90
|
+
@traks << trak
|
|
91
|
+
TrakStore << trak
|
|
92
|
+
trak
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def publish!(whodunnit: nil)
|
|
96
|
+
old_status = @status
|
|
97
|
+
@status = 'published'
|
|
98
|
+
track_event('publish', { 'status' => [old_status, @status] }, whodunnit: whodunnit)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def unpublish!(whodunnit: nil)
|
|
102
|
+
old_status = @status
|
|
103
|
+
@status = 'draft'
|
|
104
|
+
track_event('unpublish', { 'status' => [old_status, @status] }, whodunnit: whodunnit)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def update!(attrs, whodunnit: nil)
|
|
108
|
+
old_attrs = attributes
|
|
109
|
+
attrs.each { |k, v| send("#{k}=", v) if respond_to?("#{k}=") }
|
|
110
|
+
|
|
111
|
+
changeset = {}
|
|
112
|
+
attributes.each do |k, v|
|
|
113
|
+
changeset[k] = [old_attrs[k], v] if old_attrs[k] != v
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
track_event('update', changeset, whodunnit: whodunnit) unless changeset.empty?
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def add_tag!(tag_id, whodunnit: nil)
|
|
120
|
+
return if @tag_ids.include?(tag_id)
|
|
121
|
+
|
|
122
|
+
old_tags = @tag_ids.dup
|
|
123
|
+
@tag_ids << tag_id
|
|
124
|
+
track_event('add_tag', { 'tag_ids' => [old_tags, @tag_ids.dup] }, whodunnit: whodunnit)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def remove_tag!(tag_id, whodunnit: nil)
|
|
128
|
+
return unless @tag_ids.include?(tag_id)
|
|
129
|
+
|
|
130
|
+
old_tags = @tag_ids.dup
|
|
131
|
+
@tag_ids.delete(tag_id)
|
|
132
|
+
track_event('remove_tag', { 'tag_ids' => [old_tags, @tag_ids.dup] }, whodunnit: whodunnit)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def record_view!
|
|
136
|
+
@view_count += 1
|
|
137
|
+
# Don't track every view, just increment
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
class BlogComment
|
|
142
|
+
attr_accessor :id, :post_id, :author_id, :content, :status, :traks
|
|
143
|
+
|
|
144
|
+
def initialize(id:, post_id:, author_id:, content:)
|
|
145
|
+
@id = id
|
|
146
|
+
@post_id = post_id
|
|
147
|
+
@author_id = author_id
|
|
148
|
+
@content = content
|
|
149
|
+
@status = 'pending'
|
|
150
|
+
@traks = []
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def attributes
|
|
154
|
+
{ 'id' => @id, 'post_id' => @post_id, 'author_id' => @author_id, 'content' => @content, 'status' => @status }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def track_event(event, changeset, whodunnit: nil)
|
|
158
|
+
trak = SimpleTrak.new(
|
|
159
|
+
item_type: 'BlogComment',
|
|
160
|
+
item_id: @id,
|
|
161
|
+
event: event,
|
|
162
|
+
object: attributes,
|
|
163
|
+
changeset: changeset,
|
|
164
|
+
whodunnit: whodunnit
|
|
165
|
+
)
|
|
166
|
+
@traks << trak
|
|
167
|
+
TrakStore << trak
|
|
168
|
+
trak
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def approve!(whodunnit: nil)
|
|
172
|
+
old_status = @status
|
|
173
|
+
@status = 'approved'
|
|
174
|
+
track_event('approve', { 'status' => [old_status, @status] }, whodunnit: whodunnit)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def reject!(whodunnit: nil)
|
|
178
|
+
old_status = @status
|
|
179
|
+
@status = 'rejected'
|
|
180
|
+
track_event('reject', { 'status' => [old_status, @status] }, whodunnit: whodunnit)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# ============================================================================
|
|
185
|
+
# 2. CRM System
|
|
186
|
+
# ============================================================================
|
|
187
|
+
|
|
188
|
+
class CrmLead
|
|
189
|
+
attr_accessor :id, :name, :email, :company, :status, :assigned_to, :value, :traks
|
|
190
|
+
|
|
191
|
+
STATUS_FLOW = %w[new contacted qualified proposal negotiated converted lost].freeze
|
|
192
|
+
|
|
193
|
+
def initialize(id:, name:, email:, company:)
|
|
194
|
+
@id = id
|
|
195
|
+
@name = name
|
|
196
|
+
@email = email
|
|
197
|
+
@company = company
|
|
198
|
+
@status = 'new'
|
|
199
|
+
@assigned_to = nil
|
|
200
|
+
@value = 0
|
|
201
|
+
@traks = []
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def attributes
|
|
205
|
+
{ 'id' => @id, 'name' => @name, 'email' => @email, 'company' => @company,
|
|
206
|
+
'status' => @status, 'assigned_to' => @assigned_to, 'value' => @value }
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def track_event(event, changeset, whodunnit: nil)
|
|
210
|
+
trak = SimpleTrak.new(
|
|
211
|
+
item_type: 'CrmLead',
|
|
212
|
+
item_id: @id,
|
|
213
|
+
event: event,
|
|
214
|
+
object: attributes,
|
|
215
|
+
changeset: changeset,
|
|
216
|
+
whodunnit: whodunnit,
|
|
217
|
+
metadata: { 'pipeline_stage' => @status }
|
|
218
|
+
)
|
|
219
|
+
@traks << trak
|
|
220
|
+
TrakStore << trak
|
|
221
|
+
trak
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def transition_status!(new_status, whodunnit: nil)
|
|
225
|
+
return false unless STATUS_FLOW.include?(new_status)
|
|
226
|
+
|
|
227
|
+
old_status = @status
|
|
228
|
+
@status = new_status
|
|
229
|
+
track_event('status_change', { 'status' => [old_status, new_status] }, whodunnit: whodunnit)
|
|
230
|
+
true
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def assign_to!(user_id, whodunnit: nil)
|
|
234
|
+
old_assigned = @assigned_to
|
|
235
|
+
@assigned_to = user_id
|
|
236
|
+
track_event('assign', { 'assigned_to' => [old_assigned, user_id] }, whodunnit: whodunnit)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def set_value!(amount, whodunnit: nil)
|
|
240
|
+
old_value = @value
|
|
241
|
+
@value = amount
|
|
242
|
+
track_event('value_update', { 'value' => [old_value, amount] }, whodunnit: whodunnit)
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# ============================================================================
|
|
247
|
+
# 3. E-commerce Orders
|
|
248
|
+
# ============================================================================
|
|
249
|
+
|
|
250
|
+
class EcomOrder
|
|
251
|
+
attr_accessor :id, :customer_id, :status, :payment_status, :shipping_status,
|
|
252
|
+
:total, :items, :shipping_address, :tracking_number, :traks
|
|
253
|
+
|
|
254
|
+
ORDER_STATUSES = %w[pending confirmed processing shipped delivered cancelled].freeze
|
|
255
|
+
PAYMENT_STATUSES = %w[pending paid refunded failed].freeze
|
|
256
|
+
SHIPPING_STATUSES = %w[not_shipped shipped in_transit delivered returned].freeze
|
|
257
|
+
|
|
258
|
+
def initialize(id:, customer_id:, items:, total:)
|
|
259
|
+
@id = id
|
|
260
|
+
@customer_id = customer_id
|
|
261
|
+
@items = items
|
|
262
|
+
@total = total
|
|
263
|
+
@status = 'pending'
|
|
264
|
+
@payment_status = 'pending'
|
|
265
|
+
@shipping_status = 'not_shipped'
|
|
266
|
+
@shipping_address = nil
|
|
267
|
+
@tracking_number = nil
|
|
268
|
+
@traks = []
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def attributes
|
|
272
|
+
{ 'id' => @id, 'customer_id' => @customer_id, 'status' => @status,
|
|
273
|
+
'payment_status' => @payment_status, 'shipping_status' => @shipping_status,
|
|
274
|
+
'total' => @total, 'items' => @items, 'tracking_number' => @tracking_number }
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def track_event(event, changeset, whodunnit: nil)
|
|
278
|
+
trak = SimpleTrak.new(
|
|
279
|
+
item_type: 'EcomOrder',
|
|
280
|
+
item_id: @id,
|
|
281
|
+
event: event,
|
|
282
|
+
object: attributes,
|
|
283
|
+
changeset: changeset,
|
|
284
|
+
whodunnit: whodunnit
|
|
285
|
+
)
|
|
286
|
+
@traks << trak
|
|
287
|
+
TrakStore << trak
|
|
288
|
+
trak
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def update_order_status!(new_status, whodunnit: nil)
|
|
292
|
+
old_status = @status
|
|
293
|
+
@status = new_status
|
|
294
|
+
track_event('order_status_change', { 'status' => [old_status, new_status] }, whodunnit: whodunnit)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def mark_paid!(whodunnit: nil)
|
|
298
|
+
old_payment = @payment_status
|
|
299
|
+
@payment_status = 'paid'
|
|
300
|
+
track_event('payment_update', { 'payment_status' => [old_payment, 'paid'] }, whodunnit: whodunnit)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def ship!(tracking_number, whodunnit: nil)
|
|
304
|
+
old_shipping = @shipping_status
|
|
305
|
+
@shipping_status = 'shipped'
|
|
306
|
+
@tracking_number = tracking_number
|
|
307
|
+
track_event('shipping_update',
|
|
308
|
+
{ 'shipping_status' => [old_shipping, 'shipped'], 'tracking_number' => [nil, tracking_number] },
|
|
309
|
+
whodunnit: whodunnit)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def deliver!(whodunnit: nil)
|
|
313
|
+
old_shipping = @shipping_status
|
|
314
|
+
old_status = @status
|
|
315
|
+
@shipping_status = 'delivered'
|
|
316
|
+
@status = 'delivered'
|
|
317
|
+
track_event('delivery',
|
|
318
|
+
{ 'shipping_status' => [old_shipping, 'delivered'], 'status' => [old_status, 'delivered'] },
|
|
319
|
+
whodunnit: whodunnit)
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# ============================================================================
|
|
324
|
+
# 4. Configuration System
|
|
325
|
+
# ============================================================================
|
|
326
|
+
|
|
327
|
+
class AppConfig
|
|
328
|
+
attr_accessor :id, :key, :value, :environment, :updated_by, :traks
|
|
329
|
+
|
|
330
|
+
def initialize(id:, key:, value:, environment: 'production')
|
|
331
|
+
@id = id
|
|
332
|
+
@key = key
|
|
333
|
+
@value = value
|
|
334
|
+
@environment = environment
|
|
335
|
+
@updated_by = nil
|
|
336
|
+
@traks = []
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def attributes
|
|
340
|
+
{ 'id' => @id, 'key' => @key, 'value' => @value,
|
|
341
|
+
'environment' => @environment, 'updated_by' => @updated_by }
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def track_event(event, changeset, whodunnit: nil)
|
|
345
|
+
trak = SimpleTrak.new(
|
|
346
|
+
item_type: 'AppConfig',
|
|
347
|
+
item_id: @id,
|
|
348
|
+
event: event,
|
|
349
|
+
object: attributes,
|
|
350
|
+
changeset: changeset,
|
|
351
|
+
whodunnit: whodunnit,
|
|
352
|
+
metadata: { 'config_key' => @key, 'environment' => @environment }
|
|
353
|
+
)
|
|
354
|
+
@traks << trak
|
|
355
|
+
TrakStore << trak
|
|
356
|
+
trak
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def update_value!(new_value, whodunnit: nil)
|
|
360
|
+
old_value = @value
|
|
361
|
+
@value = new_value
|
|
362
|
+
@updated_by = whodunnit
|
|
363
|
+
track_event('config_update', { 'value' => [old_value, new_value] }, whodunnit: whodunnit)
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# ============================================================================
|
|
368
|
+
# 5. Document Management
|
|
369
|
+
# ============================================================================
|
|
370
|
+
|
|
371
|
+
class Document
|
|
372
|
+
attr_accessor :id, :name, :content, :version, :status, :locked_by, :approved_by, :traks
|
|
373
|
+
|
|
374
|
+
STATUSES = %w[draft pending_review approved rejected archived].freeze
|
|
375
|
+
|
|
376
|
+
def initialize(id:, name:, content:)
|
|
377
|
+
@id = id
|
|
378
|
+
@name = name
|
|
379
|
+
@content = content
|
|
380
|
+
@version = 1
|
|
381
|
+
@status = 'draft'
|
|
382
|
+
@locked_by = nil
|
|
383
|
+
@approved_by = nil
|
|
384
|
+
@traks = []
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def attributes
|
|
388
|
+
{ 'id' => @id, 'name' => @name, 'content' => @content, 'version' => @version,
|
|
389
|
+
'status' => @status, 'locked_by' => @locked_by, 'approved_by' => @approved_by }
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def track_event(event, changeset, whodunnit: nil)
|
|
393
|
+
trak = SimpleTrak.new(
|
|
394
|
+
item_type: 'Document',
|
|
395
|
+
item_id: @id,
|
|
396
|
+
event: event,
|
|
397
|
+
object: attributes,
|
|
398
|
+
changeset: changeset,
|
|
399
|
+
whodunnit: whodunnit,
|
|
400
|
+
metadata: { 'version' => @version }
|
|
401
|
+
)
|
|
402
|
+
@traks << trak
|
|
403
|
+
TrakStore << trak
|
|
404
|
+
trak
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def lock!(user_id, whodunnit: nil)
|
|
408
|
+
return false if @locked_by && @locked_by != user_id
|
|
409
|
+
|
|
410
|
+
old_locked = @locked_by
|
|
411
|
+
@locked_by = user_id
|
|
412
|
+
track_event('lock', { 'locked_by' => [old_locked, user_id] }, whodunnit: whodunnit)
|
|
413
|
+
true
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def unlock!(user_id, whodunnit: nil)
|
|
417
|
+
return false unless @locked_by == user_id
|
|
418
|
+
|
|
419
|
+
old_locked = @locked_by
|
|
420
|
+
@locked_by = nil
|
|
421
|
+
track_event('unlock', { 'locked_by' => [old_locked, nil] }, whodunnit: whodunnit)
|
|
422
|
+
true
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def update_content!(new_content, whodunnit: nil)
|
|
426
|
+
return false if @locked_by && @locked_by != whodunnit
|
|
427
|
+
|
|
428
|
+
old_content = @content
|
|
429
|
+
old_version = @version
|
|
430
|
+
@content = new_content
|
|
431
|
+
@version += 1
|
|
432
|
+
track_event('content_update',
|
|
433
|
+
{ 'content' => [old_content, new_content], 'version' => [old_version, @version] },
|
|
434
|
+
whodunnit: whodunnit)
|
|
435
|
+
true
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def submit_for_approval!(whodunnit: nil)
|
|
439
|
+
old_status = @status
|
|
440
|
+
@status = 'pending_review'
|
|
441
|
+
track_event('submit', { 'status' => [old_status, @status] }, whodunnit: whodunnit)
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def approve!(approver_id, whodunnit: nil)
|
|
445
|
+
old_status = @status
|
|
446
|
+
@status = 'approved'
|
|
447
|
+
@approved_by = approver_id
|
|
448
|
+
track_event('approve',
|
|
449
|
+
{ 'status' => [old_status, 'approved'], 'approved_by' => [nil, approver_id] },
|
|
450
|
+
whodunnit: whodunnit)
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def reject!(whodunnit: nil)
|
|
454
|
+
old_status = @status
|
|
455
|
+
@status = 'rejected'
|
|
456
|
+
track_event('reject', { 'status' => [old_status, 'rejected'] }, whodunnit: whodunnit)
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# ============================================================================
|
|
461
|
+
# 6. User Permissions
|
|
462
|
+
# ============================================================================
|
|
463
|
+
|
|
464
|
+
class Permission
|
|
465
|
+
attr_accessor :id, :user_id, :resource_type, :resource_id, :action, :granted, :traks
|
|
466
|
+
|
|
467
|
+
ACTIONS = %w[read write admin delete].freeze
|
|
468
|
+
|
|
469
|
+
def initialize(id:, user_id:, resource_type:, resource_id:, action:)
|
|
470
|
+
@id = id
|
|
471
|
+
@user_id = user_id
|
|
472
|
+
@resource_type = resource_type
|
|
473
|
+
@resource_id = resource_id
|
|
474
|
+
@action = action
|
|
475
|
+
@granted = false
|
|
476
|
+
@traks = []
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def attributes
|
|
480
|
+
{ 'id' => @id, 'user_id' => @user_id, 'resource_type' => @resource_type,
|
|
481
|
+
'resource_id' => @resource_id, 'action' => @action, 'granted' => @granted }
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
def track_event(event, changeset, whodunnit: nil)
|
|
485
|
+
trak = SimpleTrak.new(
|
|
486
|
+
item_type: 'Permission',
|
|
487
|
+
item_id: @id,
|
|
488
|
+
event: event,
|
|
489
|
+
object: attributes,
|
|
490
|
+
changeset: changeset,
|
|
491
|
+
whodunnit: whodunnit,
|
|
492
|
+
metadata: { 'resource' => "#{@resource_type}:#{@resource_id}", 'action' => @action }
|
|
493
|
+
)
|
|
494
|
+
@traks << trak
|
|
495
|
+
TrakStore << trak
|
|
496
|
+
trak
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
def grant!(whodunnit: nil)
|
|
500
|
+
old_granted = @granted
|
|
501
|
+
@granted = true
|
|
502
|
+
track_event('grant', { 'granted' => [old_granted, true] }, whodunnit: whodunnit)
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
def revoke!(whodunnit: nil)
|
|
506
|
+
old_granted = @granted
|
|
507
|
+
@granted = false
|
|
508
|
+
track_event('revoke', { 'granted' => [old_granted, false] }, whodunnit: whodunnit)
|
|
509
|
+
end
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
# ============================================================================
|
|
513
|
+
# 7. Inventory System
|
|
514
|
+
# ============================================================================
|
|
515
|
+
|
|
516
|
+
class InventoryItem
|
|
517
|
+
attr_accessor :id, :sku, :name, :quantity, :reorder_threshold, :supplier_id, :price, :traks
|
|
518
|
+
|
|
519
|
+
def initialize(id:, sku:, name:, quantity:, reorder_threshold: 10, supplier_id: nil, price: 0)
|
|
520
|
+
@id = id
|
|
521
|
+
@sku = sku
|
|
522
|
+
@name = name
|
|
523
|
+
@quantity = quantity
|
|
524
|
+
@reorder_threshold = reorder_threshold
|
|
525
|
+
@supplier_id = supplier_id
|
|
526
|
+
@price = price
|
|
527
|
+
@traks = []
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
def attributes
|
|
531
|
+
{ 'id' => @id, 'sku' => @sku, 'name' => @name, 'quantity' => @quantity,
|
|
532
|
+
'reorder_threshold' => @reorder_threshold, 'supplier_id' => @supplier_id, 'price' => @price }
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
def track_event(event, changeset, whodunnit: nil)
|
|
536
|
+
trak = SimpleTrak.new(
|
|
537
|
+
item_type: 'InventoryItem',
|
|
538
|
+
item_id: @id,
|
|
539
|
+
event: event,
|
|
540
|
+
object: attributes,
|
|
541
|
+
changeset: changeset,
|
|
542
|
+
whodunnit: whodunnit,
|
|
543
|
+
metadata: { 'sku' => @sku, 'low_stock' => below_threshold? }
|
|
544
|
+
)
|
|
545
|
+
@traks << trak
|
|
546
|
+
TrakStore << trak
|
|
547
|
+
trak
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
def below_threshold?
|
|
551
|
+
@quantity < @reorder_threshold
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
def adjust_quantity!(delta, whodunnit: nil, reason: nil)
|
|
555
|
+
old_quantity = @quantity
|
|
556
|
+
@quantity += delta
|
|
557
|
+
@quantity = [0, @quantity].max # Can't go negative
|
|
558
|
+
track_event('quantity_adjustment',
|
|
559
|
+
{ 'quantity' => [old_quantity, @quantity] },
|
|
560
|
+
whodunnit: whodunnit)
|
|
561
|
+
.tap { |t| t.metadata['reason'] = reason if reason }
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
def set_supplier!(supplier_id, whodunnit: nil)
|
|
565
|
+
old_supplier = @supplier_id
|
|
566
|
+
@supplier_id = supplier_id
|
|
567
|
+
track_event('supplier_change', { 'supplier_id' => [old_supplier, supplier_id] }, whodunnit: whodunnit)
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
def set_threshold!(new_threshold, whodunnit: nil)
|
|
571
|
+
old_threshold = @reorder_threshold
|
|
572
|
+
@reorder_threshold = new_threshold
|
|
573
|
+
track_event('threshold_change', { 'reorder_threshold' => [old_threshold, new_threshold] }, whodunnit: whodunnit)
|
|
574
|
+
end
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
# ============================================================================
|
|
578
|
+
# 8. Support Tickets
|
|
579
|
+
# ============================================================================
|
|
580
|
+
|
|
581
|
+
class SupportTicket
|
|
582
|
+
attr_accessor :id, :subject, :description, :status, :priority, :customer_id,
|
|
583
|
+
:agent_id, :category, :resolution, :traks
|
|
584
|
+
|
|
585
|
+
STATUSES = %w[open in_progress waiting_customer resolved closed].freeze
|
|
586
|
+
PRIORITIES = %w[low medium high urgent].freeze
|
|
587
|
+
|
|
588
|
+
def initialize(id:, subject:, description:, customer_id:)
|
|
589
|
+
@id = id
|
|
590
|
+
@subject = subject
|
|
591
|
+
@description = description
|
|
592
|
+
@customer_id = customer_id
|
|
593
|
+
@status = 'open'
|
|
594
|
+
@priority = 'medium'
|
|
595
|
+
@agent_id = nil
|
|
596
|
+
@category = 'general'
|
|
597
|
+
@resolution = nil
|
|
598
|
+
@traks = []
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
def attributes
|
|
602
|
+
{ 'id' => @id, 'subject' => @subject, 'description' => @description,
|
|
603
|
+
'status' => @status, 'priority' => @priority, 'customer_id' => @customer_id,
|
|
604
|
+
'agent_id' => @agent_id, 'category' => @category, 'resolution' => @resolution }
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
def track_event(event, changeset, whodunnit: nil)
|
|
608
|
+
trak = SimpleTrak.new(
|
|
609
|
+
item_type: 'SupportTicket',
|
|
610
|
+
item_id: @id,
|
|
611
|
+
event: event,
|
|
612
|
+
object: attributes,
|
|
613
|
+
changeset: changeset,
|
|
614
|
+
whodunnit: whodunnit,
|
|
615
|
+
metadata: { 'status' => @status, 'priority' => @priority }
|
|
616
|
+
)
|
|
617
|
+
@traks << trak
|
|
618
|
+
TrakStore << trak
|
|
619
|
+
trak
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
def change_status!(new_status, whodunnit: nil)
|
|
623
|
+
old_status = @status
|
|
624
|
+
@status = new_status
|
|
625
|
+
track_event('status_change', { 'status' => [old_status, new_status] }, whodunnit: whodunnit)
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
def change_priority!(new_priority, whodunnit: nil)
|
|
629
|
+
old_priority = @priority
|
|
630
|
+
@priority = new_priority
|
|
631
|
+
track_event('priority_change', { 'priority' => [old_priority, new_priority] }, whodunnit: whodunnit)
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
def assign_agent!(agent_id, whodunnit: nil)
|
|
635
|
+
old_agent = @agent_id
|
|
636
|
+
@agent_id = agent_id
|
|
637
|
+
track_event('agent_assignment', { 'agent_id' => [old_agent, agent_id] }, whodunnit: whodunnit)
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
def resolve!(resolution, whodunnit: nil)
|
|
641
|
+
old_status = @status
|
|
642
|
+
old_resolution = @resolution
|
|
643
|
+
@status = 'resolved'
|
|
644
|
+
@resolution = resolution
|
|
645
|
+
track_event('resolve',
|
|
646
|
+
{ 'status' => [old_status, 'resolved'], 'resolution' => [old_resolution, resolution] },
|
|
647
|
+
whodunnit: whodunnit)
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
def close!(whodunnit: nil)
|
|
651
|
+
old_status = @status
|
|
652
|
+
@status = 'closed'
|
|
653
|
+
track_event('close', { 'status' => [old_status, 'closed'] }, whodunnit: whodunnit)
|
|
654
|
+
end
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
# ============================================================================
|
|
658
|
+
# Scenario Tests
|
|
659
|
+
# ============================================================================
|
|
660
|
+
|
|
661
|
+
run_scenario 'Real-World Use Cases' do
|
|
662
|
+
# ==========================================================================
|
|
663
|
+
# TEST 1: Blog System - Posts with comments, authors, tags
|
|
664
|
+
# ==========================================================================
|
|
665
|
+
puts '=== TEST 1: Blog System ==='
|
|
666
|
+
|
|
667
|
+
TrakStore.clear
|
|
668
|
+
|
|
669
|
+
# Create and publish blog post
|
|
670
|
+
post = BlogPost.new(id: 1, title: 'Getting Started', body: 'Hello World', author_id: 1)
|
|
671
|
+
post.track_event('create', post.attributes, whodunnit: 1)
|
|
672
|
+
|
|
673
|
+
# Add tags
|
|
674
|
+
post.add_tag!(101, whodunnit: 1)
|
|
675
|
+
post.add_tag!(102, whodunnit: 1)
|
|
676
|
+
post.add_tag!(103, whodunnit: 1)
|
|
677
|
+
|
|
678
|
+
# Publish
|
|
679
|
+
post.publish!(whodunnit: 1)
|
|
680
|
+
|
|
681
|
+
# Update content
|
|
682
|
+
post.update!({ 'body' => 'Hello World - Updated!' }, whodunnit: 2)
|
|
683
|
+
|
|
684
|
+
# Record some views (not tracked)
|
|
685
|
+
50.times { post.record_view! }
|
|
686
|
+
|
|
687
|
+
# Unpublish
|
|
688
|
+
post.unpublish!(whodunnit: 1)
|
|
689
|
+
|
|
690
|
+
# Remove a tag
|
|
691
|
+
post.remove_tag!(102, whodunnit: 2)
|
|
692
|
+
|
|
693
|
+
# Verify tracking
|
|
694
|
+
post_traks = TrakStore.for_item('BlogPost', 1)
|
|
695
|
+
assert_equal 8, post_traks.length # create + 3 tag adds + publish + update + unpublish + tag remove
|
|
696
|
+
|
|
697
|
+
# Verify events
|
|
698
|
+
events = post_traks.map(&:event)
|
|
699
|
+
assert_includes events, 'create'
|
|
700
|
+
assert_includes events, 'publish'
|
|
701
|
+
assert_includes events, 'unpublish'
|
|
702
|
+
assert_includes events, 'add_tag'
|
|
703
|
+
assert_includes events, 'remove_tag'
|
|
704
|
+
|
|
705
|
+
# Add comments
|
|
706
|
+
comment1 = BlogComment.new(id: 1, post_id: 1, author_id: 10, content: 'Great post!')
|
|
707
|
+
comment1.track_event('create', comment1.attributes, whodunnit: 10)
|
|
708
|
+
comment1.approve!(whodunnit: 1)
|
|
709
|
+
|
|
710
|
+
comment2 = BlogComment.new(id: 2, post_id: 1, author_id: 11, content: 'Spam content')
|
|
711
|
+
comment2.track_event('create', comment2.attributes, whodunnit: 11)
|
|
712
|
+
comment2.reject!(whodunnit: 1)
|
|
713
|
+
|
|
714
|
+
comment_traks = TrakStore.for_item('BlogComment', 1)
|
|
715
|
+
assert_equal 2, comment_traks.length
|
|
716
|
+
assert_equal 'approved', comment1.status
|
|
717
|
+
|
|
718
|
+
puts ' ✓ Blog post create/update/publish/unpublish cycle tracked'
|
|
719
|
+
puts ' ✓ Blog tags add/remove tracked'
|
|
720
|
+
puts ' ✓ Blog comment moderation tracked'
|
|
721
|
+
|
|
722
|
+
# ==========================================================================
|
|
723
|
+
# TEST 2: CRM System - Lead pipeline tracking
|
|
724
|
+
# ==========================================================================
|
|
725
|
+
puts '=== TEST 2: CRM System ==='
|
|
726
|
+
|
|
727
|
+
TrakStore.clear
|
|
728
|
+
|
|
729
|
+
lead = CrmLead.new(id: 1, name: 'Acme Corp', email: 'contact@acme.com', company: 'Acme')
|
|
730
|
+
lead.track_event('create', lead.attributes, whodunnit: 1)
|
|
731
|
+
|
|
732
|
+
# Assign to sales rep
|
|
733
|
+
lead.assign_to!(100, whodunnit: 1)
|
|
734
|
+
|
|
735
|
+
# Progress through pipeline
|
|
736
|
+
lead.transition_status!('contacted', whodunnit: 100)
|
|
737
|
+
lead.transition_status!('qualified', whodunnit: 100)
|
|
738
|
+
lead.set_value!(50_000, whodunnit: 100)
|
|
739
|
+
lead.transition_status!('proposal', whodunnit: 100)
|
|
740
|
+
lead.set_value!(75_000, whodunnit: 100) # Value increased after negotiation
|
|
741
|
+
lead.transition_status!('negotiated', whodunnit: 100)
|
|
742
|
+
lead.transition_status!('converted', whodunnit: 100)
|
|
743
|
+
|
|
744
|
+
lead_traks = TrakStore.for_item('CrmLead', 1)
|
|
745
|
+
assert_equal 9, lead_traks.length
|
|
746
|
+
|
|
747
|
+
# Verify status progression
|
|
748
|
+
status_changes = lead_traks.select { |t| t.event == 'status_change' }
|
|
749
|
+
status_values = status_changes.map { |t| t.changeset['status'][1] }
|
|
750
|
+
assert_equal %w[contacted qualified proposal negotiated converted], status_values
|
|
751
|
+
|
|
752
|
+
# Verify pipeline stages in metadata
|
|
753
|
+
status_changes.each do |t|
|
|
754
|
+
assert_equal t.changeset['status'][1], t.metadata['pipeline_stage']
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
puts ' ✓ Lead status transitions tracked correctly'
|
|
758
|
+
puts ' ✓ Pipeline stage metadata preserved'
|
|
759
|
+
puts ' ✓ Value updates tracked'
|
|
760
|
+
|
|
761
|
+
# Fuzzy test: Random lead transitions
|
|
762
|
+
TrakStore.clear
|
|
763
|
+
leads = 5.times.map { |i| CrmLead.new(id: i + 1, name: "Lead #{i}", email: "lead#{i}@test.com", company: "Company #{i}") }
|
|
764
|
+
|
|
765
|
+
100.times do
|
|
766
|
+
lead = leads.sample
|
|
767
|
+
event_type = rand(3)
|
|
768
|
+
|
|
769
|
+
case event_type
|
|
770
|
+
when 0
|
|
771
|
+
current_idx = CrmLead::STATUS_FLOW.index(lead.status)
|
|
772
|
+
next_status = CrmLead::STATUS_FLOW[current_idx + 1] if current_idx && current_idx < CrmLead::STATUS_FLOW.length - 1
|
|
773
|
+
lead.transition_status!(next_status, whodunnit: rand(1..10)) if next_status
|
|
774
|
+
when 1
|
|
775
|
+
lead.assign_to!(rand(100..110), whodunnit: rand(1..10))
|
|
776
|
+
when 2
|
|
777
|
+
lead.set_value!(rand(10_000..100_000), whodunnit: rand(1..10))
|
|
778
|
+
end
|
|
779
|
+
end
|
|
780
|
+
|
|
781
|
+
total_traks = leads.sum { |l| l.traks.length }
|
|
782
|
+
assert total_traks > 50, 'Should have many tracked events'
|
|
783
|
+
puts ' ✓ Fuzzy test: 100 random lead operations tracked'
|
|
784
|
+
|
|
785
|
+
# ==========================================================================
|
|
786
|
+
# TEST 3: E-commerce Orders - Status transitions
|
|
787
|
+
# ==========================================================================
|
|
788
|
+
puts '=== TEST 3: E-commerce Orders ==='
|
|
789
|
+
|
|
790
|
+
TrakStore.clear
|
|
791
|
+
|
|
792
|
+
order = EcomOrder.new(id: 1, customer_id: 500, items: [{ sku: 'PROD-1', qty: 2, price: 25.00 }], total: 50.00)
|
|
793
|
+
order.track_event('create', order.attributes, whodunnit: 500)
|
|
794
|
+
|
|
795
|
+
# Confirm order
|
|
796
|
+
order.update_order_status!('confirmed', whodunnit: 500)
|
|
797
|
+
|
|
798
|
+
# Mark as paid
|
|
799
|
+
order.mark_paid!(whodunnit: 'payment_system')
|
|
800
|
+
|
|
801
|
+
# Process
|
|
802
|
+
order.update_order_status!('processing', whodunnit: 10)
|
|
803
|
+
|
|
804
|
+
# Ship
|
|
805
|
+
order.ship!('TRACK-12345', whodunnit: 10)
|
|
806
|
+
|
|
807
|
+
# Deliver
|
|
808
|
+
order.deliver!(whodunnit: 10)
|
|
809
|
+
|
|
810
|
+
order_traks = TrakStore.for_item('EcomOrder', 1)
|
|
811
|
+
assert_equal 6, order_traks.length
|
|
812
|
+
|
|
813
|
+
# Verify final state
|
|
814
|
+
assert_equal 'delivered', order.status
|
|
815
|
+
assert_equal 'paid', order.payment_status
|
|
816
|
+
assert_equal 'delivered', order.shipping_status
|
|
817
|
+
assert_equal 'TRACK-12345', order.tracking_number
|
|
818
|
+
|
|
819
|
+
puts ' ✓ Order status transitions tracked'
|
|
820
|
+
puts ' ✓ Payment status changes tracked'
|
|
821
|
+
puts ' ✓ Shipping with tracking number tracked'
|
|
822
|
+
|
|
823
|
+
# Fuzzy test: Multiple orders with random states
|
|
824
|
+
TrakStore.clear
|
|
825
|
+
orders = 10.times.map do |i|
|
|
826
|
+
EcomOrder.new(id: i + 1, customer_id: i + 100, items: [], total: rand(10..500))
|
|
827
|
+
end
|
|
828
|
+
|
|
829
|
+
200.times do
|
|
830
|
+
order = orders.sample
|
|
831
|
+
action = rand(6)
|
|
832
|
+
|
|
833
|
+
case action
|
|
834
|
+
when 0, 1 # Order status transition - more likely
|
|
835
|
+
current_idx = EcomOrder::ORDER_STATUSES.index(order.status)
|
|
836
|
+
next_status = EcomOrder::ORDER_STATUSES[current_idx + 1] if current_idx && current_idx < EcomOrder::ORDER_STATUSES.length - 1
|
|
837
|
+
order.update_order_status!(next_status, whodunnit: rand(1..10)) if next_status
|
|
838
|
+
when 2 # Mark paid if pending
|
|
839
|
+
order.mark_paid!(whodunnit: 'system') if order.payment_status == 'pending'
|
|
840
|
+
when 3 # Ship if not shipped and paid
|
|
841
|
+
if order.shipping_status == 'not_shipped' && order.payment_status == 'paid'
|
|
842
|
+
order.ship!("TRACK-#{rand(10_000..99_999)}", whodunnit: rand(1..10))
|
|
843
|
+
end
|
|
844
|
+
when 4 # Deliver if shipped
|
|
845
|
+
order.deliver!(whodunnit: rand(1..10)) if order.shipping_status == 'shipped'
|
|
846
|
+
when 5 # Reset order for more testing
|
|
847
|
+
order.status = 'pending'
|
|
848
|
+
order.payment_status = 'pending'
|
|
849
|
+
order.shipping_status = 'not_shipped'
|
|
850
|
+
end
|
|
851
|
+
end
|
|
852
|
+
|
|
853
|
+
total_order_traks = orders.sum { |o| o.traks.length }
|
|
854
|
+
assert total_order_traks > 50, "Should have many order events tracked, got #{total_order_traks}"
|
|
855
|
+
puts ' ✓ Fuzzy test: 200 random order operations tracked'
|
|
856
|
+
|
|
857
|
+
# ==========================================================================
|
|
858
|
+
# TEST 4: Configuration System - Audit trail
|
|
859
|
+
# ==========================================================================
|
|
860
|
+
puts '=== TEST 4: Configuration System ==='
|
|
861
|
+
|
|
862
|
+
TrakStore.clear
|
|
863
|
+
|
|
864
|
+
configs = [
|
|
865
|
+
AppConfig.new(id: 1, key: 'app.theme', value: 'light', environment: 'production'),
|
|
866
|
+
AppConfig.new(id: 2, key: 'app.max_users', value: '100', environment: 'production'),
|
|
867
|
+
AppConfig.new(id: 3, key: 'email.smtp_host', value: 'smtp.example.com', environment: 'staging')
|
|
868
|
+
]
|
|
869
|
+
|
|
870
|
+
# Initial track
|
|
871
|
+
configs.each { |c| c.track_event('create', c.attributes, whodunnit: 1) }
|
|
872
|
+
|
|
873
|
+
# Update configurations
|
|
874
|
+
configs[0].update_value!('dark', whodunnit: 'admin@example.com')
|
|
875
|
+
configs[1].update_value!('500', whodunnit: 'admin@example.com')
|
|
876
|
+
configs[0].update_value!('auto', whodunnit: 'dev@example.com')
|
|
877
|
+
configs[2].update_value!('smtp.staging.com', whodunnit: 'dev@example.com')
|
|
878
|
+
|
|
879
|
+
# Verify audit trail
|
|
880
|
+
config1_traks = TrakStore.for_item('AppConfig', 1)
|
|
881
|
+
assert_equal 3, config1_traks.length # create + 2 updates
|
|
882
|
+
|
|
883
|
+
# Verify who changed what
|
|
884
|
+
updates = config1_traks.select { |t| t.event == 'config_update' }
|
|
885
|
+
assert_equal 'admin@example.com', updates[0].whodunnit
|
|
886
|
+
assert_equal ['light', 'dark'], updates[0].changeset['value']
|
|
887
|
+
assert_equal 'dev@example.com', updates[1].whodunnit
|
|
888
|
+
assert_equal ['dark', 'auto'], updates[1].changeset['value']
|
|
889
|
+
|
|
890
|
+
# Verify metadata contains config key and environment
|
|
891
|
+
config1_traks.each do |t|
|
|
892
|
+
assert_equal 'app.theme', t.metadata['config_key']
|
|
893
|
+
assert_equal 'production', t.metadata['environment']
|
|
894
|
+
end
|
|
895
|
+
|
|
896
|
+
puts ' ✓ Config changes tracked with whodunnit'
|
|
897
|
+
puts ' ✓ Config key and environment in metadata'
|
|
898
|
+
puts ' ✓ Full audit trail available'
|
|
899
|
+
|
|
900
|
+
# Fuzzy test: Many config changes
|
|
901
|
+
TrakStore.clear
|
|
902
|
+
test_configs = 20.times.map { |i| AppConfig.new(id: i + 1, key: "config.#{i}", value: "val#{i}") }
|
|
903
|
+
|
|
904
|
+
150.times do
|
|
905
|
+
config = test_configs.sample
|
|
906
|
+
new_value = "val#{rand(1000)}"
|
|
907
|
+
config.update_value!(new_value, whodunnit: "user#{rand(1..20)}@example.com")
|
|
908
|
+
end
|
|
909
|
+
|
|
910
|
+
total_config_traks = test_configs.sum { |c| c.traks.length }
|
|
911
|
+
assert_equal 150, total_config_traks
|
|
912
|
+
puts ' ✓ Fuzzy test: 150 config changes tracked'
|
|
913
|
+
|
|
914
|
+
# ==========================================================================
|
|
915
|
+
# TEST 5: Document Management - Versions and approvals
|
|
916
|
+
# ==========================================================================
|
|
917
|
+
puts '=== TEST 5: Document Management ==='
|
|
918
|
+
|
|
919
|
+
TrakStore.clear
|
|
920
|
+
|
|
921
|
+
doc = Document.new(id: 1, name: 'Q4 Report', content: 'Initial content')
|
|
922
|
+
doc.track_event('create', doc.attributes, whodunnit: 1)
|
|
923
|
+
|
|
924
|
+
# Author locks and edits
|
|
925
|
+
doc.lock!(1, whodunnit: 1)
|
|
926
|
+
doc.update_content!('Draft content v1', whodunnit: 1)
|
|
927
|
+
doc.update_content!('Draft content v2', whodunnit: 1)
|
|
928
|
+
doc.unlock!(1, whodunnit: 1)
|
|
929
|
+
|
|
930
|
+
# Submit for approval
|
|
931
|
+
doc.submit_for_approval!(whodunnit: 1)
|
|
932
|
+
|
|
933
|
+
# Approver locks, reviews, and approves
|
|
934
|
+
doc.lock!(2, whodunnit: 2)
|
|
935
|
+
doc.approve!(2, whodunnit: 2)
|
|
936
|
+
doc.unlock!(2, whodunnit: 2)
|
|
937
|
+
|
|
938
|
+
doc_traks = TrakStore.for_item('Document', 1)
|
|
939
|
+
assert_equal 9, doc_traks.length
|
|
940
|
+
|
|
941
|
+
# Verify version progression
|
|
942
|
+
version_traks = doc_traks.select { |t| t.event == 'content_update' }
|
|
943
|
+
assert_equal 2, version_traks.length
|
|
944
|
+
assert_equal 3, doc.version
|
|
945
|
+
|
|
946
|
+
# Verify approval
|
|
947
|
+
approve_trak = doc_traks.find { |t| t.event == 'approve' }
|
|
948
|
+
assert_equal 2, approve_trak.changeset['approved_by'][1]
|
|
949
|
+
|
|
950
|
+
# Verify locked/unlocked states
|
|
951
|
+
lock_events = doc_traks.select { |t| %w[lock unlock].include?(t.event) }
|
|
952
|
+
assert_equal 4, lock_events.length
|
|
953
|
+
|
|
954
|
+
puts ' ✓ Document versions tracked'
|
|
955
|
+
puts ' ✓ Lock/unlock by users tracked'
|
|
956
|
+
puts ' ✓ Approval workflow tracked'
|
|
957
|
+
|
|
958
|
+
# Fuzzy test: Multiple documents with random operations
|
|
959
|
+
TrakStore.clear
|
|
960
|
+
docs = 5.times.map { |i| Document.new(id: i + 1, name: "Doc #{i}", content: "Content #{i}") }
|
|
961
|
+
|
|
962
|
+
100.times do
|
|
963
|
+
doc = docs.sample
|
|
964
|
+
user = rand(1..5)
|
|
965
|
+
action = rand(8)
|
|
966
|
+
|
|
967
|
+
case action
|
|
968
|
+
when 0, 1 # Lock - more likely
|
|
969
|
+
doc.lock!(user, whodunnit: user) unless doc.locked_by
|
|
970
|
+
when 2, 3 # Unlock - more likely
|
|
971
|
+
doc.unlock!(user, whodunnit: user) if doc.locked_by == user
|
|
972
|
+
when 4 # Update content
|
|
973
|
+
if !doc.locked_by || doc.locked_by == user
|
|
974
|
+
doc.update_content!("Updated content #{rand(1000)}", whodunnit: user)
|
|
975
|
+
end
|
|
976
|
+
when 5 # Submit for approval
|
|
977
|
+
doc.submit_for_approval!(whodunnit: user) if doc.status == 'draft'
|
|
978
|
+
when 6 # Approve
|
|
979
|
+
doc.approve!(user, whodunnit: user) if doc.status == 'pending_review'
|
|
980
|
+
when 7 # Reset for more testing
|
|
981
|
+
doc.status = 'draft'
|
|
982
|
+
doc.locked_by = nil
|
|
983
|
+
doc.approved_by = nil
|
|
984
|
+
end
|
|
985
|
+
end
|
|
986
|
+
|
|
987
|
+
total_doc_traks = docs.sum { |d| d.traks.length }
|
|
988
|
+
assert total_doc_traks > 20, "Should have many document events, got #{total_doc_traks}"
|
|
989
|
+
puts ' ✓ Fuzzy test: 100 random document operations tracked'
|
|
990
|
+
|
|
991
|
+
# ==========================================================================
|
|
992
|
+
# TEST 6: User Permissions - Grants and revokes
|
|
993
|
+
# ==========================================================================
|
|
994
|
+
puts '=== TEST 6: User Permissions ==='
|
|
995
|
+
|
|
996
|
+
TrakStore.clear
|
|
997
|
+
|
|
998
|
+
perm = Permission.new(id: 1, user_id: 100, resource_type: 'Project', resource_id: 5, action: 'write')
|
|
999
|
+
perm.track_event('create', perm.attributes, whodunnit: 1)
|
|
1000
|
+
|
|
1001
|
+
# Grant permission
|
|
1002
|
+
perm.grant!(whodunnit: 'admin@example.com')
|
|
1003
|
+
|
|
1004
|
+
# Revoke
|
|
1005
|
+
perm.revoke!(whodunnit: 'admin@example.com')
|
|
1006
|
+
|
|
1007
|
+
# Grant again
|
|
1008
|
+
perm.grant!(whodunnit: 'superadmin@example.com')
|
|
1009
|
+
|
|
1010
|
+
perm_traks = TrakStore.for_item('Permission', 1)
|
|
1011
|
+
assert_equal 4, perm_traks.length # create + 2 grants + 1 revoke
|
|
1012
|
+
|
|
1013
|
+
# Verify grant/revoke sequence
|
|
1014
|
+
grant_revoke = perm_traks.select { |t| %w[grant revoke].include?(t.event) }
|
|
1015
|
+
assert_equal 'grant', grant_revoke[0].event
|
|
1016
|
+
assert_equal 'revoke', grant_revoke[1].event
|
|
1017
|
+
assert_equal 'grant', grant_revoke[2].event
|
|
1018
|
+
|
|
1019
|
+
# Verify metadata
|
|
1020
|
+
perm_traks.each do |t|
|
|
1021
|
+
assert_equal 'Project:5', t.metadata['resource']
|
|
1022
|
+
assert_equal 'write', t.metadata['action']
|
|
1023
|
+
end
|
|
1024
|
+
|
|
1025
|
+
puts ' ✓ Permission grants tracked'
|
|
1026
|
+
puts ' ✓ Permission revokes tracked'
|
|
1027
|
+
puts ' ✓ Resource metadata preserved'
|
|
1028
|
+
|
|
1029
|
+
# Fuzzy test: Many permissions
|
|
1030
|
+
TrakStore.clear
|
|
1031
|
+
resources = %w[Project Document Report Dashboard Settings]
|
|
1032
|
+
actions = Permission::ACTIONS
|
|
1033
|
+
perms = []
|
|
1034
|
+
|
|
1035
|
+
50.times do |i|
|
|
1036
|
+
perm = Permission.new(
|
|
1037
|
+
id: i + 1,
|
|
1038
|
+
user_id: rand(1..20),
|
|
1039
|
+
resource_type: resources.sample,
|
|
1040
|
+
resource_id: rand(1..100),
|
|
1041
|
+
action: actions.sample
|
|
1042
|
+
)
|
|
1043
|
+
perms << perm
|
|
1044
|
+
perm.track_event('create', perm.attributes, whodunnit: 1)
|
|
1045
|
+
end
|
|
1046
|
+
|
|
1047
|
+
200.times do
|
|
1048
|
+
perm = perms.sample
|
|
1049
|
+
if perm.granted
|
|
1050
|
+
perm.revoke!(whodunnit: "admin#{rand(1..5)}")
|
|
1051
|
+
else
|
|
1052
|
+
perm.grant!(whodunnit: "admin#{rand(1..5)}")
|
|
1053
|
+
end
|
|
1054
|
+
end
|
|
1055
|
+
|
|
1056
|
+
total_perm_traks = perms.sum { |p| p.traks.length }
|
|
1057
|
+
assert_equal 250, total_perm_traks # 50 creates + 200 toggles
|
|
1058
|
+
puts ' ✓ Fuzzy test: 200 random permission changes tracked'
|
|
1059
|
+
|
|
1060
|
+
# ==========================================================================
|
|
1061
|
+
# TEST 7: Inventory System - Stock levels and thresholds
|
|
1062
|
+
# ==========================================================================
|
|
1063
|
+
puts '=== TEST 7: Inventory System ==='
|
|
1064
|
+
|
|
1065
|
+
TrakStore.clear
|
|
1066
|
+
|
|
1067
|
+
item = InventoryItem.new(id: 1, sku: 'WIDGET-001', name: 'Widget', quantity: 100, reorder_threshold: 20, price: 9.99)
|
|
1068
|
+
item.track_event('create', item.attributes, whodunnit: 1)
|
|
1069
|
+
|
|
1070
|
+
# Sell some items
|
|
1071
|
+
item.adjust_quantity!(-30, whodunnit: 'pos_system', reason: 'sale')
|
|
1072
|
+
item.adjust_quantity!(-30, whodunnit: 'pos_system', reason: 'sale')
|
|
1073
|
+
item.adjust_quantity!(-30, whodunnit: 'pos_system', reason: 'sale')
|
|
1074
|
+
|
|
1075
|
+
# Check if below threshold (100 - 90 = 10, which is below 20)
|
|
1076
|
+
assert item.below_threshold?
|
|
1077
|
+
|
|
1078
|
+
# Restock
|
|
1079
|
+
item.adjust_quantity!(100, whodunnit: 2, reason: 'restock')
|
|
1080
|
+
|
|
1081
|
+
# Change supplier
|
|
1082
|
+
item.set_supplier!(50, whodunnit: 1)
|
|
1083
|
+
|
|
1084
|
+
# Change threshold
|
|
1085
|
+
item.set_threshold!(50, whodunnit: 1)
|
|
1086
|
+
|
|
1087
|
+
item_traks = TrakStore.for_item('InventoryItem', 1)
|
|
1088
|
+
assert_equal 7, item_traks.length
|
|
1089
|
+
|
|
1090
|
+
# Verify quantity changes
|
|
1091
|
+
qty_traks = item_traks.select { |t| t.event == 'quantity_adjustment' }
|
|
1092
|
+
assert_equal 4, qty_traks.length
|
|
1093
|
+
|
|
1094
|
+
# Verify quantity tracking (100 - 90 + 100 = 110)
|
|
1095
|
+
assert_equal 110, item.quantity
|
|
1096
|
+
|
|
1097
|
+
# Verify low stock metadata
|
|
1098
|
+
low_stock_traks = item_traks.select { |t| t.metadata['low_stock'] == true }
|
|
1099
|
+
assert low_stock_traks.length > 0, 'Should have low stock events'
|
|
1100
|
+
|
|
1101
|
+
puts ' ✓ Quantity adjustments tracked with reasons'
|
|
1102
|
+
puts ' ✓ Supplier changes tracked'
|
|
1103
|
+
puts ' ✓ Threshold changes tracked'
|
|
1104
|
+
puts ' ✓ Low stock flag in metadata'
|
|
1105
|
+
|
|
1106
|
+
# Fuzzy test: Multiple items with random stock changes
|
|
1107
|
+
TrakStore.clear
|
|
1108
|
+
items = 10.times.map { |i| InventoryItem.new(id: i + 1, sku: "ITEM-#{i}", name: "Item #{i}", quantity: rand(50..200)) }
|
|
1109
|
+
|
|
1110
|
+
150.times do
|
|
1111
|
+
item = items.sample
|
|
1112
|
+
action = rand(4)
|
|
1113
|
+
|
|
1114
|
+
case action
|
|
1115
|
+
when 0, 1 # Sale (more common)
|
|
1116
|
+
delta = -rand(1..20)
|
|
1117
|
+
item.adjust_quantity!(delta, whodunnit: 'pos', reason: 'sale')
|
|
1118
|
+
when 2 # Restock
|
|
1119
|
+
delta = rand(10..50)
|
|
1120
|
+
item.adjust_quantity!(delta, whodunnit: rand(1..5), reason: 'restock')
|
|
1121
|
+
when 3 # Change threshold/supplier
|
|
1122
|
+
if rand(2) == 0
|
|
1123
|
+
item.set_threshold!(rand(10..100), whodunnit: rand(1..5))
|
|
1124
|
+
else
|
|
1125
|
+
item.set_supplier!(rand(1..20), whodunnit: rand(1..5))
|
|
1126
|
+
end
|
|
1127
|
+
end
|
|
1128
|
+
end
|
|
1129
|
+
|
|
1130
|
+
total_item_traks = items.sum { |i| i.traks.length }
|
|
1131
|
+
assert total_item_traks > 100, 'Should have many inventory events'
|
|
1132
|
+
puts ' ✓ Fuzzy test: 150 random inventory operations tracked'
|
|
1133
|
+
|
|
1134
|
+
# ==========================================================================
|
|
1135
|
+
# TEST 8: Support Tickets - Lifecycle and assignments
|
|
1136
|
+
# ==========================================================================
|
|
1137
|
+
puts '=== TEST 8: Support Tickets ==='
|
|
1138
|
+
|
|
1139
|
+
TrakStore.clear
|
|
1140
|
+
|
|
1141
|
+
ticket = SupportTicket.new(id: 1, subject: 'Cannot login', description: 'I forgot my password', customer_id: 500)
|
|
1142
|
+
ticket.track_event('create', ticket.attributes, whodunnit: 500)
|
|
1143
|
+
|
|
1144
|
+
# Escalate priority
|
|
1145
|
+
ticket.change_priority!('high', whodunnit: 500)
|
|
1146
|
+
|
|
1147
|
+
# Assign to agent
|
|
1148
|
+
ticket.assign_agent!(100, whodunnit: 'system')
|
|
1149
|
+
|
|
1150
|
+
# Agent picks up
|
|
1151
|
+
ticket.change_status!('in_progress', whodunnit: 100)
|
|
1152
|
+
|
|
1153
|
+
# Need more info
|
|
1154
|
+
ticket.change_status!('waiting_customer', whodunnit: 100)
|
|
1155
|
+
|
|
1156
|
+
# Customer responds
|
|
1157
|
+
ticket.change_status!('in_progress', whodunnit: 500)
|
|
1158
|
+
|
|
1159
|
+
# Resolve
|
|
1160
|
+
ticket.resolve!('Password reset link sent', whodunnit: 100)
|
|
1161
|
+
|
|
1162
|
+
# Close after confirmation
|
|
1163
|
+
ticket.close!(whodunnit: 500)
|
|
1164
|
+
|
|
1165
|
+
ticket_traks = TrakStore.for_item('SupportTicket', 1)
|
|
1166
|
+
assert_equal 8, ticket_traks.length
|
|
1167
|
+
|
|
1168
|
+
# Verify status progression - includes status_change, resolve, and close events
|
|
1169
|
+
status_traks = ticket_traks.select { |t| %w[status_change resolve close].include?(t.event) }
|
|
1170
|
+
status_values = status_traks.map { |t| t.changeset['status'][1] }
|
|
1171
|
+
assert_equal %w[in_progress waiting_customer in_progress resolved closed], status_values
|
|
1172
|
+
|
|
1173
|
+
# Verify final state
|
|
1174
|
+
assert_equal 'closed', ticket.status
|
|
1175
|
+
assert_equal 'high', ticket.priority
|
|
1176
|
+
assert_equal 100, ticket.agent_id
|
|
1177
|
+
|
|
1178
|
+
puts ' ✓ Ticket status transitions tracked'
|
|
1179
|
+
puts ' ✓ Priority changes tracked'
|
|
1180
|
+
puts ' ✓ Agent assignments tracked'
|
|
1181
|
+
puts ' ✓ Resolution tracked'
|
|
1182
|
+
|
|
1183
|
+
# Fuzzy test: Multiple tickets with random operations
|
|
1184
|
+
TrakStore.clear
|
|
1185
|
+
tickets = 15.times.map { |i| SupportTicket.new(id: i + 1, subject: "Issue #{i}", description: "Description #{i}", customer_id: rand(100..200)) }
|
|
1186
|
+
|
|
1187
|
+
200.times do
|
|
1188
|
+
ticket = tickets.sample
|
|
1189
|
+
action = rand(7)
|
|
1190
|
+
|
|
1191
|
+
case action
|
|
1192
|
+
when 0, 1 # Status transition - more likely
|
|
1193
|
+
current_idx = SupportTicket::STATUSES.index(ticket.status)
|
|
1194
|
+
next_status = SupportTicket::STATUSES[current_idx + 1] if current_idx && current_idx < SupportTicket::STATUSES.length - 1
|
|
1195
|
+
ticket.change_status!(next_status, whodunnit: rand(1..50)) if next_status
|
|
1196
|
+
when 2, 3 # Priority change - more likely
|
|
1197
|
+
ticket.change_priority!(SupportTicket::PRIORITIES.sample, whodunnit: rand(1..50))
|
|
1198
|
+
when 4 # Agent assignment
|
|
1199
|
+
ticket.assign_agent!(rand(1..10), whodunnit: rand(1..10)) if ticket.agent_id.nil?
|
|
1200
|
+
when 5 # Resolve if in progress
|
|
1201
|
+
ticket.resolve!("Resolution #{rand(1000)}", whodunnit: ticket.agent_id || rand(1..10)) if ticket.status == 'in_progress'
|
|
1202
|
+
when 6 # Reset for more testing
|
|
1203
|
+
ticket.status = 'open'
|
|
1204
|
+
ticket.agent_id = nil
|
|
1205
|
+
end
|
|
1206
|
+
end
|
|
1207
|
+
|
|
1208
|
+
total_ticket_traks = tickets.sum { |t| t.traks.length }
|
|
1209
|
+
assert total_ticket_traks > 50, "Should have many ticket events, got #{total_ticket_traks}"
|
|
1210
|
+
puts ' ✓ Fuzzy test: 200 random ticket operations tracked'
|
|
1211
|
+
|
|
1212
|
+
puts "\n=== Scenario 37: Real-World Use Cases PASSED ==="
|
|
1213
|
+
end
|