model_timeline 0.1.1 → 0.1.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6a6a1184662cefc583a463199b9ba93d0962f3c3c4d6fbcf2940074f0d508c71
4
- data.tar.gz: 2380ec1607093282a3de23d9d20760ff4a09db115b1f84dc572cc8a57af5ac49
3
+ metadata.gz: 11d959076d546ea778aaad610afaf48458725f4c9dbc8f46148de1f137f31604
4
+ data.tar.gz: 5f6a6d3321e2277690d06b84bae0aebb3c3d1cde1f0760b26ae444d100151428
5
5
  SHA512:
6
- metadata.gz: 92139b98feed631b10c0cf102c0e702f9c8e901f9dfa3d769487c45611c95ceb22271233032d05e78d91fb23417d56e42e5571bcab46ee10c3dedd201c89a12f
7
- data.tar.gz: d69052dbea970f0edade2611df282db871087ac850ccabce1f0aa56888d86fed35e8c399f603578cd364fbc5ce11fb8b69c6eb0ff9df7891fb6640a482fa0d97
6
+ metadata.gz: 9224951a8e3958ef129b183f7c9535485e3c085d85aca91169c303f7443a2f79fba9906c5a4d5897f708edc73a8ac4efc5661391ae609e2da91a9fa000f856a5
7
+ data.tar.gz: 54928db8b6fb566b047379dc21227f2388b2fe67e0f1b07ddf0410ed124d57428a6adedf3e726d83c14f7aec1f0837f95ce13adbe20b4295a4280d36794d3942
data/README.md CHANGED
@@ -385,11 +385,6 @@ Configure RSpec to work with ModelTimeline:
385
385
  require 'model_timeline/rspec'
386
386
 
387
387
  RSpec.configure do |config|
388
- # This disables ModelTimeline by default in tests for better performance
389
- config.before(:suite) do
390
- ModelTimeline.disable!
391
- end
392
-
393
388
  # Include the RSpec helpers and matchers
394
389
  config.include ModelTimeline::RSpec
395
390
  end
@@ -440,17 +435,65 @@ expect(user).to have_timeline_entries
440
435
  expect(user).to have_timeline_entries(3)
441
436
 
442
437
  # Check for entries with a specific action
443
- expect(user).to have_timelined_action(:update)
438
+ expect(user).to have_timeline_entry_action(:update)
444
439
 
445
440
  # Check if a specific attribute was changed
446
- expect(user).to have_timelined_change(:email)
441
+ expect(user).to have_timeline_entry_change(:email)
447
442
 
448
443
  # Check if an attribute was changed to a specific value
449
- expect(user).to have_timelined_entry(:status, 'active')
444
+ expect(user).to have_timeline_entry(:status, 'active')
445
+
446
+ # Check if an entry was created with expected metadata
447
+ expect(user).to have_timeline_entry_metadata(foo: 'bar', baz: 'biz')
450
448
  ```
451
449
 
452
450
  These matchers make it easy to test that your application is correctly tracking model changes.
453
451
 
452
+ ##### Matchers for custom models/tables
453
+
454
+ You can add a configuration in your support file to create matchers for your association.
455
+ Given a model like this:
456
+
457
+ ```ruby
458
+ class User < ApplicationRecord
459
+ has_timeline :security_events, class_name: 'SecurityTimelineEntry'
460
+ end
461
+ ```
462
+
463
+ You should set a configuration in your RSpec support file with:
464
+ ```ruby
465
+ # spec/support/model_timeline.rb
466
+ require 'model_timeline/rspec'
467
+
468
+ RSpec.configure do |config|
469
+ # Include the RSpec helpers and matchers
470
+ config.include ModelTimeline::RSpec
471
+ config.include ModelTimeline::RSpec::Matchers.define_timeline_matchers_for(:security_events)
472
+ end
473
+ ```
474
+
475
+ Then you have those new matchers:
476
+
477
+ ```ruby
478
+ # Check for any timeline entries
479
+ expect(user).to have_security_events
480
+
481
+ # Check for a specific number of entries
482
+ expect(user).to have_security_events(3)
483
+
484
+ # Check for entries with a specific action
485
+ expect(user).to have_security_event_action(:update)
486
+
487
+ # Check if a specific attribute was changed
488
+ expect(user).to have_security_event_change(:email)
489
+
490
+ # Check if an attribute was changed to a specific value
491
+ expect(user).to have_security_event(:status, 'active')
492
+
493
+ # Check if an entry was created with expected metadata
494
+ expect(user).to have_security_event_metadata(foo: 'bar', baz: 'biz')
495
+ ```
496
+
454
497
 
455
498
  ## License
456
499
 
@@ -2,7 +2,6 @@
2
2
 
3
3
  module ModelTimeline
4
4
  module RSpec
5
- # rubocop:disable Naming/PredicateName
6
5
  # Custom RSpec matchers for testing model timeline entries
7
6
  #
8
7
  # These matchers help you test the timeline entries created by the ModelTimeline gem.
@@ -16,58 +15,78 @@ module ModelTimeline
16
15
  # expect(user).to have_timeline_entries(3)
17
16
  #
18
17
  # # Check for specific action
19
- # expect(user).to have_timelined_action(:update)
18
+ # expect(user).to have_timeline_entry_action(:update)
20
19
  #
21
20
  # # Check for changes to a specific attribute
22
- # expect(user).to have_timelined_change(:email)
21
+ # expect(user).to have_timeline_entry_change(:email)
23
22
  #
24
23
  # # Check for specific value change
25
- # expect(user).to have_timelined_entry(:status, "active")
24
+ # expect(user).to have_timeline_entry(:status, "active")
26
25
  module Matchers
27
- # Check if a model has timeline entries
28
- #
29
- # @param count [Integer, nil] The expected number of timeline entries (optional)
30
- # @return [HaveTimelineEntriesMatcher] A matcher that checks for timeline entries
31
- # @example Without count parameter
32
- # expect(user).to have_timeline_entries
33
- # @example With count parameter
34
- # expect(user).to have_timeline_entries(3)
35
- def have_timeline_entries(count = nil)
36
- HaveTimelineEntriesMatcher.new(count)
26
+ def self.included(base)
27
+ base.include define_timeline_matchers_for(:timeline_entries)
37
28
  end
38
29
 
39
- # Check if a specific action was recorded in the timeline
40
- #
41
- # @param action [String, Symbol] The action name to look for
42
- # @return [HaveTimelinedAction] A matcher that checks for a specific action
43
- # @example
44
- # expect(user).to have_timelined_action(:create)
45
- # expect(user).to have_timelined_action("update")
46
- def have_timelined_action(action)
47
- HaveTimelinedAction.new(action)
48
- end
30
+ def self.define_timeline_matchers_for(association_name)
31
+ association_name_singularized = association_name.to_s.singularize
49
32
 
50
- # Check if a specific attribute change was recorded in the timeline
51
- #
52
- # @param attribute [String, Symbol] The attribute name to check for changes
53
- # @return [HaveTimelinedChange] A matcher that checks if an attribute was changed
54
- # @example
55
- # expect(user).to have_timelined_change(:email)
56
- # expect(user).to have_timelined_change("status")
57
- def have_timelined_change(attribute)
58
- HaveTimelinedChange.new(attribute)
59
- end
33
+ Module.new do
34
+ # Check if a model has timeline entries
35
+ #
36
+ # @param count [Integer, nil] The expected number of timeline entries (optional)
37
+ # @return [HaveTimelineEntriesMatcher] A matcher that checks for timeline entries
38
+ # @example Without count parameter
39
+ # expect(user).to have_timeline_entries
40
+ # @example With count parameter
41
+ # expect(user).to have_timeline_entries(3)
42
+ define_method(:"have_#{association_name}") do |count = nil|
43
+ HaveTimelineEntriesMatcher.new(count, association_name)
44
+ end
60
45
 
61
- # Check if an attribute was changed to a specific value in the timeline
62
- #
63
- # @param attribute [String, Symbol] The attribute name to check
64
- # @param value [Object] The value to check for
65
- # @return [HaveTimelinedEntry] A matcher that checks for specific attribute values
66
- # @example
67
- # expect(user).to have_timelined_entry(:status, "active")
68
- # expect(user).to have_timelined_entry(:role, :admin)
69
- def have_timelined_entry(attribute, value)
70
- HaveTimelinedEntry.new(attribute, value)
46
+ # Check if a specific action was recorded in the timeline
47
+ #
48
+ # @param action [String, Symbol] The action name to look for
49
+ # @return [HaveTimelineAction] A matcher that checks for a specific action
50
+ # @example
51
+ # expect(user).to have_timeline_entry_action(:create)
52
+ # expect(user).to have_timeline_entry_action("update")
53
+ define_method(:"have_#{association_name_singularized}_action") do |action|
54
+ HaveTimelineAction.new(action, association_name)
55
+ end
56
+
57
+ # Check if a specific attribute change was recorded in the timeline
58
+ #
59
+ # @param attribute [String, Symbol] The attribute name to check for changes
60
+ # @return [HaveTimelineChange] A matcher that checks if an attribute was changed
61
+ # @example
62
+ # expect(user).to have_timeline_entry_change(:email)
63
+ # expect(user).to have_timeline_entry_change("status")
64
+ define_method(:"have_#{association_name_singularized}_change") do |attribute|
65
+ HaveTimelineChange.new(attribute, association_name)
66
+ end
67
+
68
+ # Check if an attribute was changed to a specific value in the timeline
69
+ #
70
+ # @param attribute [String, Symbol] The attribute name to check
71
+ # @param value [Object] The value to check for
72
+ # @return [HaveTimelineEntry] A matcher that checks for specific attribute values
73
+ # @example
74
+ # expect(user).to have_timeline_entry(:status, "active")
75
+ # expect(user).to have_timeline_entry(:role, :admin)
76
+ define_method(:"have_#{association_name_singularized}") do |attribute, value|
77
+ HaveTimelineEntry.new(attribute, value, association_name)
78
+ end
79
+
80
+ # Check if a model has timeline entries with specific metadata
81
+ #
82
+ # @param expected_metadata [Hash] The metadata key-value pairs to check for
83
+ # @return [HaveTimelineEntryMetadata] A matcher that checks for specific metadata
84
+ # @example
85
+ # expect(user).to have_timeline_entry_metadata(foo: 'bar', baz: 'biz')
86
+ define_method(:"have_#{association_name_singularized}_metadata") do |expected_metadata|
87
+ HaveTimelineEntryMetadata.new(expected_metadata, association_name)
88
+ end
89
+ end
71
90
  end
72
91
 
73
92
  # RSpec matcher to check if a model has timeline entries
@@ -77,8 +96,9 @@ module ModelTimeline
77
96
  # Initialize the matcher
78
97
  #
79
98
  # @param expected_count [Integer, nil] The expected number of timeline entries
80
- def initialize(expected_count)
99
+ def initialize(expected_count, association_name)
81
100
  @expected_count = expected_count
101
+ @association_name = association_name
82
102
  end
83
103
 
84
104
  # Check if the subject matches the expectations
@@ -88,9 +108,9 @@ module ModelTimeline
88
108
  def matches?(subject)
89
109
  @subject = subject
90
110
  if @expected_count.nil?
91
- subject.timeline_entries.any?
111
+ subject.public_send(@association_name).any?
92
112
  else
93
- subject.timeline_entries.count == @expected_count
113
+ subject.public_send(@association_name).count == @expected_count
94
114
  end
95
115
  end
96
116
 
@@ -102,7 +122,7 @@ module ModelTimeline
102
122
  "expected #{@subject} to have timeline entries, but found none"
103
123
  else
104
124
  "expected #{@subject} to have #{@expected_count} timeline entries, " \
105
- "but found #{@subject.timeline_entries.count}"
125
+ "but found #{@subject.public_send(@association_name).count}"
106
126
  end
107
127
  end
108
128
 
@@ -111,7 +131,7 @@ module ModelTimeline
111
131
  # @return [String] A descriptive failure message for negated expectations
112
132
  def failure_message_when_negated
113
133
  if @expected_count.nil?
114
- "expected #{@subject} not to have any timeline entries, but found #{@subject.timeline_entries.count}"
134
+ "expected #{@subject} not to have any timeline entries, but found #{@subject.public_send(@association_name).count}"
115
135
  else
116
136
  "expected #{@subject} not to have #{@expected_count} timeline entries, but found exactly that many"
117
137
  end
@@ -121,12 +141,13 @@ module ModelTimeline
121
141
  # RSpec matcher to check if a model has timeline entries with a specific action
122
142
  #
123
143
  # @api private
124
- class HaveTimelinedAction
144
+ class HaveTimelineAction
125
145
  # Initialize the matcher
126
146
  #
127
147
  # @param action [String, Symbol] The action to look for
128
- def initialize(action)
148
+ def initialize(action, association_name)
129
149
  @action = action.to_s
150
+ @association_name = association_name
130
151
  end
131
152
 
132
153
  # Check if the subject matches the expectations
@@ -135,7 +156,7 @@ module ModelTimeline
135
156
  # @return [Boolean] True if the model has timeline entries with the specified action
136
157
  def matches?(subject)
137
158
  @subject = subject
138
- subject.timeline_entries.where(action: @action).exists?
159
+ @subject.public_send(@association_name).where(action: @action).exists?
139
160
  end
140
161
 
141
162
  # Message displayed when the expectation fails
@@ -156,12 +177,13 @@ module ModelTimeline
156
177
  # RSpec matcher to check if a model has timeline entries with changes to a specific attribute
157
178
  #
158
179
  # @api private
159
- class HaveTimelinedChange
180
+ class HaveTimelineChange
160
181
  # Initialize the matcher
161
182
  #
162
183
  # @param attribute [String, Symbol] The attribute to check for changes
163
- def initialize(attribute)
184
+ def initialize(attribute, association_name)
164
185
  @attribute = attribute.to_s
186
+ @association_name = association_name
165
187
  end
166
188
 
167
189
  # Check if the subject matches the expectations
@@ -170,7 +192,7 @@ module ModelTimeline
170
192
  # @return [Boolean] True if the model has timeline entries with changes to the specified attribute
171
193
  def matches?(subject)
172
194
  @subject = subject
173
- subject.timeline_entries.with_changed_attribute(@attribute).exists?
195
+ @subject.public_send(@association_name).with_changed_attribute(@attribute).exists?
174
196
  end
175
197
 
176
198
  # Message displayed when the expectation fails
@@ -191,14 +213,15 @@ module ModelTimeline
191
213
  # RSpec matcher to check if a model has timeline entries where an attribute changed to a specific value
192
214
  #
193
215
  # @api private
194
- class HaveTimelinedEntry
216
+ class HaveTimelineEntry
195
217
  # Initialize the matcher
196
218
  #
197
219
  # @param attribute [String, Symbol] The attribute to check
198
220
  # @param value [Object] The value the attribute should have changed to
199
- def initialize(attribute, value)
221
+ def initialize(attribute, value, association_name)
200
222
  @attribute = attribute.to_s
201
223
  @value = value
224
+ @association_name = association_name
202
225
  end
203
226
 
204
227
  # Check if the subject matches the expectations
@@ -207,7 +230,7 @@ module ModelTimeline
207
230
  # @return [Boolean] True if the model has timeline entries where the attribute changed to the specified value
208
231
  def matches?(subject)
209
232
  @subject = subject
210
- subject.timeline_entries.with_changed_value(@attribute, @value).exists?
233
+ @subject.public_send(@association_name).with_changed_value(@attribute, @value).exists?
211
234
  end
212
235
 
213
236
  # Message displayed when the expectation fails
@@ -224,7 +247,52 @@ module ModelTimeline
224
247
  "expected #{@subject} not to have tracked '#{@attribute}' changing to '#{@value}', but such a change was found"
225
248
  end
226
249
  end
250
+
251
+ # RSpec matcher to check if a model has timeline entries with specific metadata
252
+ #
253
+ # @api private
254
+ class HaveTimelineEntryMetadata
255
+ # Initialize the matcher
256
+ #
257
+ # @param expected_metadata [Hash] The metadata key-value pairs to check for
258
+ # @param association_name [Symbol] The name of the timeline association
259
+ def initialize(expected_metadata, association_name)
260
+ @expected_metadata = expected_metadata
261
+ @association_name = association_name
262
+ end
263
+
264
+ # Check if the subject matches the expectations
265
+ #
266
+ # @param subject [Object] The model to check for timeline entries
267
+ # @return [Boolean] True if the model has timeline entries with the specified metadata
268
+ def matches?(subject)
269
+ @subject = subject
270
+
271
+ # Build a query that checks for each key-value pair in the metadata
272
+ entries = subject.public_send(@association_name)
273
+
274
+ # Construct queries for each metadata key-value pair
275
+ @expected_metadata.all? do |key, value|
276
+ # Use a JSON containment query to check if the metadata contains the key-value pair
277
+ # This syntax works with PostgreSQL's JSONB containment operator @>
278
+ entries.where('metadata @> ?', { key.to_s => value }.to_json).exists?
279
+ end
280
+ end
281
+
282
+ # Message displayed when the expectation fails
283
+ #
284
+ # @return [String] A descriptive failure message
285
+ def failure_message
286
+ "expected #{@subject} to have timeline entries with metadata #{@expected_metadata.inspect}, but none was found"
287
+ end
288
+
289
+ # Message displayed when the negated expectation fails
290
+ #
291
+ # @return [String] A descriptive failure message for negated expectations
292
+ def failure_message_when_negated
293
+ "expected #{@subject} not to have timeline entries with metadata #{@expected_metadata.inspect}, but such entries were found"
294
+ end
295
+ end
227
296
  end
228
- # rubocop:enable Naming/PredicateName
229
297
  end
230
298
  end
@@ -20,7 +20,7 @@ module ModelTimeline
20
20
  # describe User, :with_timeline do
21
21
  # it "records timeline entries when updated" do
22
22
  # user.update(name: "New Name")
23
- # expect(user).to have_timelined_change(:name)
23
+ # expect(user).to have_timeline_entry_change(:name)
24
24
  # end
25
25
  # end
26
26
  module RSpec
@@ -56,6 +56,10 @@ module ModelTimeline
56
56
  # rubocop:disable Metrics/PerceivedComplexity
57
57
  # rubocop:disable Naming/PredicateName
58
58
  def has_timeline(*args, **kwargs)
59
+ if defined?(Rails.env) && Rails.env.development? && caller.any? { |line| line.include?('reload!') }
60
+ loggers.clear # Reset loggers during reload! in console
61
+ end
62
+
59
63
  association_name = args.first.is_a?(Symbol) ? args.shift : :timeline_entries
60
64
 
61
65
  klass = (kwargs[:class_name] || 'ModelTimeline::TimelineEntry').constantize
@@ -185,8 +189,7 @@ module ModelTimeline
185
189
 
186
190
  # Collects metadata for the timeline entry
187
191
  #
188
- # @param meta_config [Hash] The metadata configuration
189
- # @param config_key [String] The configuration key for this model
192
+ # @param meta_config [Hash, Proc] The metadata configuration
190
193
  # @return [Hash] Collected metadata
191
194
  def collect_metadata(meta_config)
192
195
  metadata = {}
@@ -194,22 +197,46 @@ module ModelTimeline
194
197
  # First, add any thread-level metadata
195
198
  metadata.merge!(ModelTimeline.metadata)
196
199
 
197
- # Then, add any model-specific metadata defined in config
198
- meta_config.each do |key, value|
199
- resolved_value = case value
200
- when Proc
201
- instance_exec(self, &value)
202
- when Symbol
203
- respond_to?(value) ? send(value) : value
204
- else
205
- value
206
- end
207
- metadata[key] = resolved_value
208
- end
200
+ # Then, add any model-specific metadata from config
201
+ metadata.merge!(resolve_metadata_value(meta_config))
209
202
 
210
203
  metadata
211
204
  end
212
205
 
206
+ # Recursively resolves metadata values
207
+ #
208
+ # @param value [Hash, Proc, Symbol, Object] The value to resolve
209
+ # @return [Hash, Object] The resolved value
210
+ def resolve_metadata_value(value)
211
+ case value
212
+ when Proc
213
+ # If it's a Proc, execute it and process the result recursively
214
+ proc_result = instance_exec(self, &value)
215
+ proc_result.is_a?(Hash) ? process_metadata_hash(proc_result) : proc_result
216
+ when Hash
217
+ # If it's a Hash, process each key-value pair
218
+ process_metadata_hash(value)
219
+ when Symbol
220
+ # If it's a Symbol, try to call the method
221
+ respond_to?(value) ? send(value) : value
222
+ else
223
+ # Any other value, return as-is
224
+ value
225
+ end
226
+ end
227
+
228
+ # Processes a metadata hash by resolving each value
229
+ #
230
+ # @param hash [Hash] The hash to process
231
+ # @return [Hash] The processed hash
232
+ def process_metadata_hash(hash)
233
+ result = {}
234
+ hash.each do |key, val|
235
+ result[key] = resolve_metadata_value(val)
236
+ end
237
+ result
238
+ end
239
+
213
240
  def column_metadata(config)
214
241
  metadata = collect_metadata(config[:meta])
215
242
  column_names = config[:klass].column_names.map(&:to_sym)
@@ -5,5 +5,5 @@ module ModelTimeline
5
5
  # Follows semantic versioning (https://semver.org/).
6
6
  #
7
7
  # @return [String] The current version in the format "MAJOR.MINOR.PATCH"
8
- VERSION = '0.1.1'
8
+ VERSION = '0.1.3'
9
9
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: model_timeline
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alexandre Stapenhorst
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-06-03 00:00:00.000000000 Z
11
+ date: 2025-06-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: pg