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