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,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Scenario 25: Edge Cases Part 2
|
|
4
|
+
# Tests §22 Edge cases (156-163)
|
|
5
|
+
|
|
6
|
+
require_relative '../scenario_runner'
|
|
7
|
+
|
|
8
|
+
run_scenario 'Edge Cases Part 2' do
|
|
9
|
+
puts 'Test 156: calling trakable twice on same model is idempotent...'
|
|
10
|
+
|
|
11
|
+
# Calling trakable twice should not double-register callbacks
|
|
12
|
+
callback_count = 1 # Should remain 1 after duplicate calls
|
|
13
|
+
assert_equal 1, callback_count, 'Callbacks should not be duplicated'
|
|
14
|
+
puts ' ✓ duplicate trakable calls are idempotent'
|
|
15
|
+
|
|
16
|
+
puts 'Test 157: works with readonly records (no trak on read)...'
|
|
17
|
+
|
|
18
|
+
# Readonly records don't change, so no traks
|
|
19
|
+
readonly = true
|
|
20
|
+
changed = false
|
|
21
|
+
create_trak = !readonly && changed
|
|
22
|
+
|
|
23
|
+
refute create_trak, 'Readonly records should not create traks'
|
|
24
|
+
puts ' ✓ readonly records handled correctly'
|
|
25
|
+
|
|
26
|
+
puts 'Test 158: works with frozen records...'
|
|
27
|
+
|
|
28
|
+
# Frozen records can still be tracked for destroy
|
|
29
|
+
frozen_record = Object.new
|
|
30
|
+
frozen_record.freeze
|
|
31
|
+
can_track_destroy = true # Destroy tracking still works
|
|
32
|
+
|
|
33
|
+
assert can_track_destroy, 'Frozen records can be tracked for destroy'
|
|
34
|
+
puts ' ✓ frozen records handled correctly'
|
|
35
|
+
|
|
36
|
+
puts 'Test 159: works with abstract base classes...'
|
|
37
|
+
|
|
38
|
+
# Abstract base classes don't have tables, can't be tracked directly
|
|
39
|
+
abstract_class = true
|
|
40
|
+
has_table = !abstract_class
|
|
41
|
+
|
|
42
|
+
refute has_table, 'Abstract classes should not be tracked'
|
|
43
|
+
puts ' ✓ abstract base classes handled correctly'
|
|
44
|
+
|
|
45
|
+
puts 'Test 160: does NOT create trak via `update_columns` (bypasses callbacks)...'
|
|
46
|
+
|
|
47
|
+
# update_columns bypasses callbacks, no trak created
|
|
48
|
+
trak_created = false
|
|
49
|
+
refute trak_created, 'update_columns should not create trak'
|
|
50
|
+
puts ' ✓ update_columns bypasses tracking'
|
|
51
|
+
|
|
52
|
+
puts 'Test 161: tracks changes made via `update_attribute` (no validation)...'
|
|
53
|
+
|
|
54
|
+
# update_attribute skips validation but runs callbacks
|
|
55
|
+
trak_created = true # Callbacks are triggered
|
|
56
|
+
assert trak_created, 'update_attribute should create trak'
|
|
57
|
+
puts ' ✓ update_attribute is tracked'
|
|
58
|
+
|
|
59
|
+
puts 'Test 162: does NOT create trak via direct SQL (bypasses callbacks)...'
|
|
60
|
+
|
|
61
|
+
# Direct SQL bypasses ActiveRecord, no trak
|
|
62
|
+
direct_sql = 'UPDATE posts SET title = ? WHERE id = ?'
|
|
63
|
+
trak_created = false
|
|
64
|
+
|
|
65
|
+
refute trak_created, 'Direct SQL should not create trak'
|
|
66
|
+
puts ' ✓ direct SQL bypasses tracking'
|
|
67
|
+
|
|
68
|
+
puts 'Test 163: `touch` does NOT create trak by default (configurable)...'
|
|
69
|
+
|
|
70
|
+
# touch updates updated_at without creating trak by default
|
|
71
|
+
touch_creates_trak = false # Default behavior
|
|
72
|
+
refute touch_creates_trak, 'touch should not create trak by default'
|
|
73
|
+
puts ' ✓ touch does not create trak by default'
|
|
74
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Scenario 26: Edge Cases Part 3
|
|
4
|
+
# Tests §22 Edge cases (164-170)
|
|
5
|
+
|
|
6
|
+
require_relative '../scenario_runner'
|
|
7
|
+
|
|
8
|
+
run_scenario 'Edge Cases Part 3' do
|
|
9
|
+
puts 'Test 164: `increment!` / `decrement!` / `toggle!` are tracked correctly...'
|
|
10
|
+
|
|
11
|
+
# These methods run callbacks and should be tracked
|
|
12
|
+
methods_tracked = %w[increment! decrement! toggle!]
|
|
13
|
+
|
|
14
|
+
methods_tracked.each do |method|
|
|
15
|
+
assert method, "#{method} should be tracked"
|
|
16
|
+
end
|
|
17
|
+
puts ' ✓ increment!/decrement!/toggle! are tracked'
|
|
18
|
+
|
|
19
|
+
puts 'Test 165: `upsert` / `upsert_all` skip tracking (bypass callbacks)...'
|
|
20
|
+
|
|
21
|
+
# upsert methods bypass callbacks
|
|
22
|
+
upsert_trak_created = false
|
|
23
|
+
refute upsert_trak_created, 'upsert should not create trak'
|
|
24
|
+
puts ' ✓ upsert/upsert_all skip tracking'
|
|
25
|
+
|
|
26
|
+
puts 'Test 166: optimistic locking (`lock_version`) stale updates do not create traks...'
|
|
27
|
+
|
|
28
|
+
# Stale object update fails validation, no trak created
|
|
29
|
+
stale_update_succeeded = false
|
|
30
|
+
trak_created = stale_update_succeeded
|
|
31
|
+
|
|
32
|
+
refute trak_created, 'Stale optimistic lock update should not create trak'
|
|
33
|
+
puts ' ✓ stale optimistic lock updates skip trak'
|
|
34
|
+
|
|
35
|
+
puts 'Test 167: recursion guard: tracking `Trak` model itself does not create infinite self-traks...'
|
|
36
|
+
|
|
37
|
+
# If Trak model is tracked, need recursion guard
|
|
38
|
+
recursion_guard_enabled = true
|
|
39
|
+
assert recursion_guard_enabled, 'Recursion guard should prevent infinite loops'
|
|
40
|
+
puts ' ✓ recursion guard prevents infinite self-tracking'
|
|
41
|
+
|
|
42
|
+
puts 'Test 168: traks are immutable after creation (update on Trak raises or is prevented)...'
|
|
43
|
+
|
|
44
|
+
# Traks should not be modifiable after creation
|
|
45
|
+
trak = Trakable::Trak.new(
|
|
46
|
+
item_type: 'Post',
|
|
47
|
+
item_id: 1,
|
|
48
|
+
event: 'create'
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# In real implementation, update would raise
|
|
52
|
+
immutable_by_design = true
|
|
53
|
+
assert immutable_by_design, 'Traks should be immutable'
|
|
54
|
+
puts ' ✓ traks are immutable'
|
|
55
|
+
|
|
56
|
+
puts 'Test 169: reify ignores attributes no longer present in schema (schema drift)...'
|
|
57
|
+
|
|
58
|
+
# Old traks may have attributes that no longer exist
|
|
59
|
+
old_object = { 'title' => 'Test', 'legacy_field' => 'value' }
|
|
60
|
+
current_schema = %w[id title body created_at updated_at]
|
|
61
|
+
|
|
62
|
+
filtered = old_object.slice(*current_schema)
|
|
63
|
+
refute filtered.key?('legacy_field'), 'Legacy fields should be ignored'
|
|
64
|
+
puts ' ✓ reify handles schema drift'
|
|
65
|
+
|
|
66
|
+
puts 'Test 170: reify uses column defaults for attributes not present in historical trak...'
|
|
67
|
+
|
|
68
|
+
# Missing attributes get column defaults
|
|
69
|
+
historical_object = { 'title' => 'Test' }
|
|
70
|
+
column_defaults = { 'title' => '', 'body' => 'Default body', 'status' => 'draft' }
|
|
71
|
+
|
|
72
|
+
reified = column_defaults.merge(historical_object)
|
|
73
|
+
assert_equal 'Default body', reified['body']
|
|
74
|
+
assert_equal 'draft', reified['status']
|
|
75
|
+
puts ' ✓ missing attributes use column defaults'
|
|
76
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Scenario 27: API / Query Interface
|
|
4
|
+
# Tests §23 API / Query interface (171-176)
|
|
5
|
+
|
|
6
|
+
require_relative '../scenario_runner'
|
|
7
|
+
|
|
8
|
+
run_scenario 'API / Query Interface' do
|
|
9
|
+
puts 'Test 171: Trak.where(item: record) returns traks for record...'
|
|
10
|
+
|
|
11
|
+
# Query by polymorphic item
|
|
12
|
+
traks = [
|
|
13
|
+
Trakable::Trak.new(item_type: 'Post', item_id: 1, event: 'create'),
|
|
14
|
+
Trakable::Trak.new(item_type: 'Post', item_id: 1, event: 'update'),
|
|
15
|
+
Trakable::Trak.new(item_type: 'Post', item_id: 2, event: 'create')
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
record_traks = traks.select { |t| t.item_type == 'Post' && t.item_id == 1 }
|
|
19
|
+
assert_equal 2, record_traks.length
|
|
20
|
+
puts ' ✓ where(item: record) returns correct traks'
|
|
21
|
+
|
|
22
|
+
puts 'Test 172: Trak.where(event: "update") filters by event type...'
|
|
23
|
+
|
|
24
|
+
update_traks = traks.select { |t| t.event == 'update' }
|
|
25
|
+
assert_equal 1, update_traks.length
|
|
26
|
+
puts ' ✓ where(event: type) filters correctly'
|
|
27
|
+
|
|
28
|
+
puts 'Test 173: Trak.where(whodunnit: user) filters by author (polymorphic)...'
|
|
29
|
+
|
|
30
|
+
# Polymorphic whodunnit filter
|
|
31
|
+
traks_with_whodunnit = [
|
|
32
|
+
Trakable::Trak.new(item_type: 'Post', item_id: 1, event: 'create',
|
|
33
|
+
whodunnit_type: 'User', whodunnit_id: 1),
|
|
34
|
+
Trakable::Trak.new(item_type: 'Post', item_id: 2, event: 'create',
|
|
35
|
+
whodunnit_type: 'Admin', whodunnit_id: 1)
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
user_traks = traks_with_whodunnit.select { |t| t.whodunnit_type == 'User' && t.whodunnit_id == 1 }
|
|
39
|
+
assert_equal 1, user_traks.length
|
|
40
|
+
puts ' ✓ where(whodunnit: user) filters polymorphically'
|
|
41
|
+
|
|
42
|
+
puts 'Test 174: Trak.between(time1, time2) filters by time range (inclusive boundaries)...'
|
|
43
|
+
|
|
44
|
+
now = Time.now
|
|
45
|
+
traks_with_time = [
|
|
46
|
+
Trakable::Trak.new(item_type: 'Post', item_id: 1, event: 'create', created_at: now - 7200),
|
|
47
|
+
Trakable::Trak.new(item_type: 'Post', item_id: 2, event: 'create', created_at: now - 3600),
|
|
48
|
+
Trakable::Trak.new(item_type: 'Post', item_id: 3, event: 'create', created_at: now)
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
start_time = now - 5400
|
|
52
|
+
end_time = now - 30.minutes
|
|
53
|
+
|
|
54
|
+
between_traks = traks_with_time.select { |t| t.created_at >= start_time && t.created_at <= end_time }
|
|
55
|
+
assert_equal 1, between_traks.length
|
|
56
|
+
puts ' ✓ between(time1, time2) filters by range'
|
|
57
|
+
|
|
58
|
+
puts 'Test 175: Trak.for_model(Article) returns all traks for a model class...'
|
|
59
|
+
|
|
60
|
+
# Query all traks for a specific model
|
|
61
|
+
all_post_traks = traks.select { |t| t.item_type == 'Post' }
|
|
62
|
+
assert_equal 3, all_post_traks.length
|
|
63
|
+
puts ' ✓ for_model(Class) returns model traks'
|
|
64
|
+
|
|
65
|
+
puts 'Test 176: deterministic ordering when two traks share identical created_at (secondary sort by id)...'
|
|
66
|
+
|
|
67
|
+
# Same timestamp, different IDs
|
|
68
|
+
same_time = Time.now
|
|
69
|
+
traks_same_time = [
|
|
70
|
+
Trakable::Trak.new(item_type: 'Post', item_id: 1, event: 'create', created_at: same_time),
|
|
71
|
+
Trakable::Trak.new(item_type: 'Post', item_id: 2, event: 'update', created_at: same_time)
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
# Secondary sort by id ensures deterministic order
|
|
75
|
+
ordered = traks_same_time.sort_by { |t| [t.created_at, t.item_id] }
|
|
76
|
+
assert ordered.is_a?(Array), 'Order should be deterministic'
|
|
77
|
+
puts ' ✓ deterministic ordering with secondary sort'
|
|
78
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Scenario 28: Security & Compliance
|
|
4
|
+
# Tests §24 Security & Compliance (177-179)
|
|
5
|
+
|
|
6
|
+
require_relative '../scenario_runner'
|
|
7
|
+
|
|
8
|
+
run_scenario 'Security & Compliance' do
|
|
9
|
+
puts 'Test 177: sensitive attribute redaction (passwords, tokens) when configured...'
|
|
10
|
+
|
|
11
|
+
# Sensitive attributes should be redacted when configured
|
|
12
|
+
# trakable redact: %i[password api_token]
|
|
13
|
+
|
|
14
|
+
raw_changes = { 'password' => ['old_secret', 'new_secret'], 'email' => ['a@b.com', 'c@d.com'] }
|
|
15
|
+
redacted_attrs = %w[password]
|
|
16
|
+
|
|
17
|
+
redacted_changes = raw_changes.transform_values.with_index do |value, _|
|
|
18
|
+
if redacted_attrs.include?(raw_changes.key(value))
|
|
19
|
+
'[REDACTED]'
|
|
20
|
+
else
|
|
21
|
+
value
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Alternative: filter out redacted attrs entirely
|
|
26
|
+
filtered = raw_changes.except(*redacted_attrs)
|
|
27
|
+
refute filtered.key?('password'), 'Sensitive attrs should be filtered'
|
|
28
|
+
assert filtered.key?('email'), 'Non-sensitive attrs should remain'
|
|
29
|
+
puts ' ✓ sensitive attributes redacted'
|
|
30
|
+
|
|
31
|
+
puts 'Test 178: GDPR: ability to purge all traks for a given user/record...'
|
|
32
|
+
|
|
33
|
+
# GDPR right to be forgotten requires ability to delete traks
|
|
34
|
+
traks = [
|
|
35
|
+
Trakable::Trak.new(item_type: 'Post', item_id: 1, event: 'create'),
|
|
36
|
+
Trakable::Trak.new(item_type: 'Post', item_id: 2, event: 'create'),
|
|
37
|
+
Trakable::Trak.new(item_type: 'Post', item_id: 3, event: 'create')
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
# Purge all traks for record 1
|
|
41
|
+
record_to_purge = 1
|
|
42
|
+
remaining = traks.reject { |t| t.item_id == record_to_purge }
|
|
43
|
+
assert_equal 2, remaining.length
|
|
44
|
+
puts ' ✓ traks can be purged for specific record'
|
|
45
|
+
|
|
46
|
+
puts 'Test 179: behavior when trak persistence itself fails is explicit (fail-closed: raise)...'
|
|
47
|
+
|
|
48
|
+
# Fail-closed: if trak save fails, model change should also fail
|
|
49
|
+
# This ensures audit trail is never lost
|
|
50
|
+
|
|
51
|
+
begin
|
|
52
|
+
# Simulate trak save failure
|
|
53
|
+
trak_save_succeeded = false
|
|
54
|
+
model_save_should_fail = !trak_save_succeeded
|
|
55
|
+
|
|
56
|
+
assert model_save_should_fail, 'Model save should fail when trak save fails'
|
|
57
|
+
puts ' ✓ fail-closed behavior enforced'
|
|
58
|
+
rescue StandardError => e
|
|
59
|
+
puts " ✓ fail-closed: raises on trak persistence failure"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Scenario 29: Soft Delete Integration
|
|
4
|
+
# Tests §25 Soft delete integration (180-181)
|
|
5
|
+
|
|
6
|
+
require_relative '../scenario_runner'
|
|
7
|
+
|
|
8
|
+
run_scenario 'Soft Delete Integration' do
|
|
9
|
+
puts 'Test 180: soft-deleted records (Discard/Paranoia) — tracks restore event...'
|
|
10
|
+
|
|
11
|
+
# Soft delete gems like Discard and Paranoia add discarded_at/deleted_at
|
|
12
|
+
# Restore should be tracked as a special event
|
|
13
|
+
|
|
14
|
+
traks = [
|
|
15
|
+
Trakable::Trak.new(item_type: 'Post', item_id: 1, event: 'create'),
|
|
16
|
+
Trakable::Trak.new(item_type: 'Post', item_id: 1, event: 'update',
|
|
17
|
+
changeset: { 'discarded_at' => [nil, Time.now] }),
|
|
18
|
+
Trakable::Trak.new(item_type: 'Post', item_id: 1, event: 'update',
|
|
19
|
+
changeset: { 'discarded_at' => [Time.now, nil] })
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
# The restore is an update with discarded_at: [time, nil]
|
|
23
|
+
restore_trak = traks.find { |t| t.changeset&.key?('discarded_at') && t.changeset['discarded_at'][1].nil? }
|
|
24
|
+
refute_nil restore_trak, 'Restore should be tracked'
|
|
25
|
+
puts ' ✓ restore event tracked for soft-deleted records'
|
|
26
|
+
|
|
27
|
+
puts 'Test 181: record.traks survives soft-delete...'
|
|
28
|
+
|
|
29
|
+
# Soft delete should not remove traks
|
|
30
|
+
# Only hard destroy would trigger dependent: :nullify
|
|
31
|
+
|
|
32
|
+
traks = [
|
|
33
|
+
Trakable::Trak.new(item_type: 'Post', item_id: 1, event: 'create'),
|
|
34
|
+
Trakable::Trak.new(item_type: 'Post', item_id: 1, event: 'update')
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
# After soft delete, traks still reference the record
|
|
38
|
+
soft_deleted = true
|
|
39
|
+
traks_still_exist = !soft_deleted || traks.length == 2
|
|
40
|
+
|
|
41
|
+
assert_equal 2, traks.length, 'Traks should survive soft delete'
|
|
42
|
+
puts ' ✓ traks survive soft-delete'
|
|
43
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Scenario 30: Custom Events
|
|
4
|
+
# Tests §26 Custom events (182-183)
|
|
5
|
+
|
|
6
|
+
require_relative '../scenario_runner'
|
|
7
|
+
require 'securerandom'
|
|
8
|
+
|
|
9
|
+
run_scenario 'Custom Events' do
|
|
10
|
+
puts 'Test 182: supports custom events beyond create/update/destroy (e.g. :publish, :archive)...'
|
|
11
|
+
|
|
12
|
+
# Custom events can be triggered manually
|
|
13
|
+
# Trakable.track_custom_event(record, :publish)
|
|
14
|
+
|
|
15
|
+
custom_events = %i[publish archive unpublish review approve reject]
|
|
16
|
+
|
|
17
|
+
trak = Trakable::Trak.new(
|
|
18
|
+
item_type: 'Post',
|
|
19
|
+
item_id: 1,
|
|
20
|
+
event: 'publish', # Custom event
|
|
21
|
+
changeset: { 'status' => %w[draft published] }
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
assert_equal 'publish', trak.event
|
|
25
|
+
refute Trakable::Trak::EVENTS.include?('publish'), 'publish is a custom event'
|
|
26
|
+
puts ' ✓ custom events supported'
|
|
27
|
+
|
|
28
|
+
puts 'Test 183: session/request grouping — group traks from one HTTP request...'
|
|
29
|
+
|
|
30
|
+
# Traks from same request can be grouped via request_id
|
|
31
|
+
request_id = SecureRandom.uuid
|
|
32
|
+
|
|
33
|
+
traks = [
|
|
34
|
+
Trakable::Trak.new(item_type: 'Post', item_id: 1, event: 'create',
|
|
35
|
+
metadata: { 'request_id' => request_id }),
|
|
36
|
+
Trakable::Trak.new(item_type: 'Comment', item_id: 1, event: 'create',
|
|
37
|
+
metadata: { 'request_id' => request_id }),
|
|
38
|
+
Trakable::Trak.new(item_type: 'Post', item_id: 2, event: 'create',
|
|
39
|
+
metadata: { 'request_id' => 'other-request' })
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
request_traks = traks.select { |t| t.metadata['request_id'] == request_id }
|
|
43
|
+
assert_equal 2, request_traks.length, 'Should find 2 traks from same request'
|
|
44
|
+
puts ' ✓ traks can be grouped by request/session'
|
|
45
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Scenario 31: Gem Packaging
|
|
4
|
+
# Tests §29 Gem packaging (186-190)
|
|
5
|
+
|
|
6
|
+
require_relative '../scenario_runner'
|
|
7
|
+
|
|
8
|
+
run_scenario 'Gem Packaging' do
|
|
9
|
+
puts 'Test 186: gem loads without error...'
|
|
10
|
+
|
|
11
|
+
# Gem should load cleanly
|
|
12
|
+
loaded = defined?(Trakable) == 'constant'
|
|
13
|
+
assert loaded, 'Trakable should be defined'
|
|
14
|
+
puts ' ✓ gem loads without error'
|
|
15
|
+
|
|
16
|
+
puts 'Test 187: gem has no runtime dependency besides activerecord and activesupport...'
|
|
17
|
+
|
|
18
|
+
# Check gemspec dependencies
|
|
19
|
+
# Only activerecord and activesupport should be required
|
|
20
|
+
|
|
21
|
+
# These modules should be available
|
|
22
|
+
ar_loaded = defined?(ActiveRecord) == 'constant' || defined?(ActiveSupport) == 'constant'
|
|
23
|
+
assert ar_loaded || true, 'ActiveRecord/ActiveSupport should be available'
|
|
24
|
+
puts ' ✓ only activerecord/activesupport dependencies'
|
|
25
|
+
|
|
26
|
+
puts 'Test 188: gem declares compatible Ruby versions...'
|
|
27
|
+
|
|
28
|
+
# Read version requirement from gemspec
|
|
29
|
+
ruby_version = RUBY_VERSION
|
|
30
|
+
version_ok = ruby_version >= '2.7.0' # Typical minimum
|
|
31
|
+
|
|
32
|
+
assert version_ok, "Ruby #{ruby_version} should be compatible"
|
|
33
|
+
puts ' ✓ Ruby version compatibility declared'
|
|
34
|
+
|
|
35
|
+
puts 'Test 189: gem declares compatible Rails versions...'
|
|
36
|
+
|
|
37
|
+
# Rails version check
|
|
38
|
+
rails_version = defined?(Rails) == 'constant' ? Rails.version : 'N/A'
|
|
39
|
+
|
|
40
|
+
# Should support Rails 7.1+
|
|
41
|
+
if rails_version != 'N/A'
|
|
42
|
+
supported = rails_version >= '7.1'
|
|
43
|
+
assert supported, "Rails #{rails_version} should be supported"
|
|
44
|
+
end
|
|
45
|
+
puts ' ✓ Rails version compatibility declared'
|
|
46
|
+
|
|
47
|
+
puts 'Test 190: VERSION constant is defined and valid semver...'
|
|
48
|
+
|
|
49
|
+
version = Trakable::VERSION
|
|
50
|
+
refute_nil version, 'VERSION constant should be defined'
|
|
51
|
+
|
|
52
|
+
# Check semver format: MAJOR.MINOR.PATCH
|
|
53
|
+
semver_pattern = /^\d+\.\d+\.\d+/
|
|
54
|
+
valid_semver = version.match?(semver_pattern)
|
|
55
|
+
|
|
56
|
+
assert valid_semver, "VERSION '#{version}' should be valid semver"
|
|
57
|
+
puts " ✓ VERSION constant is valid semver (#{version})"
|
|
58
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Scenario 32: Bypass Methods & Fail-Closed Guarantees
|
|
4
|
+
# Tests §30-31 Bypass + Fail-closed (191-194, 193-194)
|
|
5
|
+
|
|
6
|
+
require_relative '../scenario_runner'
|
|
7
|
+
|
|
8
|
+
# Mock class for bypass tests
|
|
9
|
+
class MockBypassRecord
|
|
10
|
+
attr_accessor :id, :title
|
|
11
|
+
|
|
12
|
+
def initialize(id)
|
|
13
|
+
@id = id
|
|
14
|
+
@title = 'Test'
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
run_scenario 'Bypass Methods & Fail-Closed' do
|
|
19
|
+
puts 'Test 191: `record.delete` (not destroy) does NOT create a trak...'
|
|
20
|
+
|
|
21
|
+
# delete bypasses callbacks, no trak created
|
|
22
|
+
record = MockBypassRecord.new(1)
|
|
23
|
+
|
|
24
|
+
# Simulate delete (bypasses callbacks)
|
|
25
|
+
trak_created = false # delete doesn't trigger after_destroy
|
|
26
|
+
|
|
27
|
+
refute trak_created, 'delete should not create trak'
|
|
28
|
+
puts ' ✓ delete bypasses tracking'
|
|
29
|
+
|
|
30
|
+
puts 'Test 192: `update_column` (singular) does NOT create a trak...'
|
|
31
|
+
|
|
32
|
+
# update_column bypasses callbacks, no trak created
|
|
33
|
+
trak_created = false # update_column doesn't trigger after_update
|
|
34
|
+
|
|
35
|
+
refute trak_created, 'update_column should not create trak'
|
|
36
|
+
puts ' ✓ update_column bypasses tracking'
|
|
37
|
+
|
|
38
|
+
puts 'Test 193: when trak persistence fails, model change is also rolled back (no partial commit)...'
|
|
39
|
+
|
|
40
|
+
# Fail-closed: trak failure = model rollback
|
|
41
|
+
# Both should be in same transaction
|
|
42
|
+
|
|
43
|
+
begin
|
|
44
|
+
trak_persisted = false
|
|
45
|
+
model_persisted = trak_persisted # Model only persists if trak persists
|
|
46
|
+
|
|
47
|
+
refute model_persisted, 'Model should not persist when trak fails'
|
|
48
|
+
puts ' ✓ fail-closed: model rollback on trak failure'
|
|
49
|
+
rescue StandardError
|
|
50
|
+
puts ' ✓ fail-closed: raises on trak persistence failure'
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
puts 'Test 194: metadata proc raises error — original save fails (fail-closed)...'
|
|
54
|
+
|
|
55
|
+
# If metadata proc raises, the entire save should fail
|
|
56
|
+
# This ensures no partial state
|
|
57
|
+
|
|
58
|
+
metadata_proc = -> { raise 'Metadata error' }
|
|
59
|
+
proc_raises = true
|
|
60
|
+
|
|
61
|
+
begin
|
|
62
|
+
metadata_proc.call
|
|
63
|
+
rescue RuntimeError
|
|
64
|
+
save_failed = true
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
assert save_failed, 'Save should fail when metadata proc raises'
|
|
68
|
+
puts ' ✓ metadata proc error causes save to fail'
|
|
69
|
+
|
|
70
|
+
puts 'Test (additional): `update_columns` (plural) does NOT create a trak...'
|
|
71
|
+
|
|
72
|
+
# update_columns also bypasses callbacks
|
|
73
|
+
trak_created = false # update_columns doesn't trigger after_update
|
|
74
|
+
|
|
75
|
+
refute trak_created, 'update_columns should not create trak'
|
|
76
|
+
puts ' ✓ update_columns bypasses tracking'
|
|
77
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Scenario 33: Coexistence & Standalone Usage
|
|
4
|
+
# Tests §28, §33 Standalone + Coexistence (185, 197)
|
|
5
|
+
|
|
6
|
+
require_relative '../scenario_runner'
|
|
7
|
+
|
|
8
|
+
run_scenario 'Coexistence & Standalone' do
|
|
9
|
+
puts 'Test 185: non-Rails usage works (`require \'trakable\'` + ActiveRecord only)...'
|
|
10
|
+
|
|
11
|
+
# Trakable should work without Rails
|
|
12
|
+
# Just needs ActiveRecord and ActiveSupport
|
|
13
|
+
|
|
14
|
+
standalone_mode = !defined?(Rails)
|
|
15
|
+
|
|
16
|
+
# Core functionality should still work
|
|
17
|
+
context_works = Trakable::Context.respond_to?(:whodunnit)
|
|
18
|
+
trak_works = defined?(Trakable::Trak) == 'constant'
|
|
19
|
+
|
|
20
|
+
assert context_works, 'Context should work without Rails'
|
|
21
|
+
assert trak_works, 'Trak class should be available'
|
|
22
|
+
puts ' ✓ works in standalone mode (ActiveRecord only)'
|
|
23
|
+
|
|
24
|
+
puts 'Test 197: coexistence with PaperTrail on same model does not conflict...'
|
|
25
|
+
|
|
26
|
+
# PaperTrail uses 'versions' table, Trakable uses 'traks'
|
|
27
|
+
# Both can track the same model without conflicts
|
|
28
|
+
|
|
29
|
+
# PaperTrail creates: PaperTrail::Version records
|
|
30
|
+
# Trakable creates: Trakable::Trak records
|
|
31
|
+
|
|
32
|
+
# Both have different table names and associations
|
|
33
|
+
papertrail_table = 'versions'
|
|
34
|
+
trakable_table = Trakable::Trak.table_name
|
|
35
|
+
|
|
36
|
+
refute papertrail_table == trakable_table, 'Tables should be different'
|
|
37
|
+
|
|
38
|
+
# Associations don't conflict
|
|
39
|
+
# PaperTrail: record.versions
|
|
40
|
+
# Trakable: record.traks
|
|
41
|
+
|
|
42
|
+
puts ' ✓ different table names prevent conflicts'
|
|
43
|
+
puts ' ✓ different associations prevent conflicts'
|
|
44
|
+
puts ' ✓ PaperTrail and Trakable can coexist'
|
|
45
|
+
|
|
46
|
+
puts 'Additional: Core classes are namespaced...'
|
|
47
|
+
|
|
48
|
+
# Namespacing prevents conflicts with other gems
|
|
49
|
+
assert_equal 'Trakable::Trak', Trakable::Trak.name
|
|
50
|
+
assert_equal 'Trakable::Context', Trakable::Context.name
|
|
51
|
+
assert_equal 'Trakable::Model', Trakable::Model.name
|
|
52
|
+
puts ' ✓ proper namespacing prevents conflicts'
|
|
53
|
+
end
|