rails_audit_log 0.9.0 → 1.1.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.
@@ -1,4 +1,21 @@
1
1
  module RailsAuditLog
2
+ # Include in +ApplicationController+ (or any controller) to automatically
3
+ # set and clear the current actor for every request, so that audit entries
4
+ # written during the request are tagged with the signed-in user.
5
+ #
6
+ # == Usage
7
+ #
8
+ # class ApplicationController < ActionController::Base
9
+ # include RailsAuditLog::Controller
10
+ # audit_log_actor { current_user }
11
+ # end
12
+ #
13
+ # The block passed to {audit_log_actor} is evaluated in controller instance
14
+ # context on every request via a +before_action+. The actor is cleared in an
15
+ # +after_action+ so it never leaks between requests.
16
+ #
17
+ # Request metadata (+remote_ip+, +user_agent+) is captured automatically when
18
+ # {RailsAuditLog.capture_request_metadata} is +true+.
2
19
  module Controller
3
20
  extend ActiveSupport::Concern
4
21
 
@@ -10,10 +27,22 @@ module RailsAuditLog
10
27
  end
11
28
 
12
29
  class_methods do
30
+ # Registers a block that resolves the current actor for each request.
31
+ # The block is evaluated in controller instance context, so any helper
32
+ # method available to the controller (e.g. +current_user+) can be used.
33
+ #
34
+ # @yield block evaluated in controller context; should return the actor
35
+ # @return [void]
36
+ # @example
37
+ # audit_log_actor { current_user }
38
+ #
39
+ # # With a one-argument lambda style (actor = the controller instance)
40
+ # audit_log_actor { |c| c.current_user }
13
41
  def audit_log_actor(&block)
14
42
  @audit_log_actor_block = block
15
43
  end
16
44
 
45
+ # @api private
17
46
  def audit_log_actor_block
18
47
  @audit_log_actor_block
19
48
  end
@@ -1,4 +1,5 @@
1
1
  module RailsAuditLog
2
+ # @api private
2
3
  class ApplicationController < ActionController::Base
3
4
  include Pagy::Method
4
5
 
@@ -1,4 +1,5 @@
1
1
  module RailsAuditLog
2
+ # @api private
2
3
  class AuditLogEntriesController < ApplicationController
3
4
  def index
4
5
  set_filters
@@ -1,4 +1,5 @@
1
1
  module RailsAuditLog
2
+ # @api private
2
3
  class ResourcesController < ApplicationController
3
4
  def show
4
5
  @item_type = params[:item_type]
@@ -1,4 +1,5 @@
1
1
  module RailsAuditLog
2
+ # @api private
2
3
  module ApplicationHelper
3
4
  def format_diff_value(value)
4
5
  return "—" if value.nil?
@@ -1,4 +1,5 @@
1
1
  module RailsAuditLog
2
+ # @api private
2
3
  class ApplicationJob < ActiveJob::Base
3
4
  end
4
5
  end
@@ -0,0 +1,58 @@
1
+ module RailsAuditLog
2
+ # Background job that prunes {AuditLogEntry} records for every audited model
3
+ # that has a configured retention policy.
4
+ #
5
+ # Enqueue on a recurring schedule via your job backend (Solid Queue,
6
+ # Sidekiq, GoodJob, etc.):
7
+ #
8
+ # RailsAuditLog::PruneAuditLogJob.perform_later
9
+ #
10
+ # The job iterates over every +item_type+ present in +audit_log_entries+,
11
+ # resolves the effective +retention_period+ / +version_limit+ for that model,
12
+ # and deletes entries that exceed either constraint. Pruning is always scoped
13
+ # to one +item_type+ at a time so models do not interfere with each other.
14
+ class PruneAuditLogJob < ApplicationJob
15
+ def perform
16
+ AuditLogEntry.distinct.pluck(:item_type).each do |item_type|
17
+ prune_item_type(item_type)
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def prune_item_type(item_type)
24
+ klass = item_type.safe_constantize
25
+ period = resolve_period(klass)
26
+ limit = resolve_limit(klass)
27
+ return unless period || limit
28
+
29
+ scope = AuditLogEntry.where(item_type: item_type)
30
+
31
+ scope.where(created_at: ..period.ago).delete_all if period
32
+
33
+ return unless limit
34
+
35
+ scope.select(:item_id).distinct.pluck(:item_id).each do |item_id|
36
+ record_scope = scope.where(item_id: item_id)
37
+ excess = record_scope.count - limit
38
+ record_scope.order(id: :asc).limit(excess).delete_all if excess > 0
39
+ end
40
+ end
41
+
42
+ def resolve_period(klass)
43
+ if klass.respond_to?(:_audit_log_retain_for)
44
+ klass._audit_log_retain_for || RailsAuditLog.retention_period
45
+ else
46
+ RailsAuditLog.retention_period
47
+ end
48
+ end
49
+
50
+ def resolve_limit(klass)
51
+ if klass.respond_to?(:_audit_log_version_limit)
52
+ klass._audit_log_version_limit || RailsAuditLog.version_limit
53
+ else
54
+ RailsAuditLog.version_limit
55
+ end
56
+ end
57
+ end
58
+ end
@@ -1,20 +1,21 @@
1
1
  module RailsAuditLog
2
2
  class WriteAuditLogJob < ApplicationJob
3
- def perform(entry_attrs, version_limit: nil)
3
+ def perform(entry_attrs, version_limit: nil, retention_period: nil)
4
4
  AuditLogEntry.create!(entry_attrs)
5
5
 
6
- return unless version_limit
7
-
8
6
  item_type = entry_attrs["item_type"]
9
7
  item_id = entry_attrs["item_id"]
10
- count = AuditLogEntry.where(item_type: item_type, item_id: item_id).count
11
- excess = count - version_limit
12
- return unless excess > 0
8
+ scope = AuditLogEntry.where(item_type: item_type, item_id: item_id)
9
+
10
+ if retention_period
11
+ scope.where(created_at: ..retention_period.ago).delete_all
12
+ end
13
13
 
14
- AuditLogEntry.where(item_type: item_type, item_id: item_id)
15
- .order(id: :asc)
16
- .limit(excess)
17
- .delete_all
14
+ if version_limit
15
+ count = scope.count
16
+ excess = count - version_limit
17
+ scope.order(id: :asc).limit(excess).delete_all if excess > 0
18
+ end
18
19
  end
19
20
  end
20
21
  end
@@ -1,4 +1,5 @@
1
1
  module RailsAuditLog
2
+ # @api private
2
3
  class ApplicationRecord < ActiveRecord::Base
3
4
  self.abstract_class = true
4
5
  end
@@ -1,4 +1,23 @@
1
1
  module RailsAuditLog
2
+ # Represents a single audited event (+create+, +update+, or +destroy+) for
3
+ # one ActiveRecord record.
4
+ #
5
+ # == Columns
6
+ #
7
+ # [event] One of <tt>"create"</tt>, <tt>"update"</tt>, <tt>"destroy"</tt>.
8
+ # [item_type] Class name of the audited record (e.g. <tt>"Article"</tt>).
9
+ # [item_id] Primary key of the audited record.
10
+ # [object_changes] JSON hash of attribute changes in <tt>[from, to]</tt> form.
11
+ # All three event types use the same format:
12
+ # +create+ stores <tt>[nil, new]</tt> for every column,
13
+ # +update+ stores <tt>[old, new]</tt> for changed columns only,
14
+ # +destroy+ stores <tt>[final, nil]</tt> for every column.
15
+ # [object] JSON snapshot of the record's full attributes before the
16
+ # change (stored when {RailsAuditLog.store_snapshot} is +true+).
17
+ # [whodunnit_snapshot] Display name of the actor at the time of the change.
18
+ # [actor_type / actor_id] Polymorphic reference to the actor record.
19
+ # [reason] Optional free-text reason string.
20
+ # [metadata] Arbitrary JSON hash (request IP, custom lambdas, etc.).
2
21
  class AuditLogEntry < ApplicationRecord
3
22
  self.table_name = "audit_log_entries"
4
23
 
@@ -6,33 +25,63 @@ module RailsAuditLog
6
25
  BLOB_COLUMNS = %w[object_changes object metadata].freeze
7
26
  PERIODS = { "1h" => 1.hour, "24h" => 24.hours, "7d" => 7.days }.freeze
8
27
 
28
+ # @api private
9
29
  def self.configure_connection!
10
30
  return unless (opts = RailsAuditLog.connects_to)
11
31
 
12
32
  connects_to(**opts)
13
33
  end
14
34
 
15
- belongs_to :item, polymorphic: true, optional: true
35
+ belongs_to :item, polymorphic: true, optional: true
16
36
  belongs_to :actor, polymorphic: true, optional: true
17
37
 
18
- validates :event, presence: true, inclusion: { in: EVENTS }
38
+ validates :event, presence: true, inclusion: { in: EVENTS }
19
39
  validates :item_type, presence: true
20
- validates :item_id, presence: true
21
- validate :metadata_must_be_a_hash
40
+ validates :item_id, presence: true
41
+ validate :metadata_must_be_a_hash
22
42
 
23
- # Event scopes
43
+ # @!group Event scopes
44
+
45
+ # Entries for +create+ events.
46
+ # @return [ActiveRecord::Relation]
24
47
  scope :created_events, -> { where(event: "create") }
48
+
49
+ # Entries for +update+ events.
50
+ # @return [ActiveRecord::Relation]
25
51
  scope :updated_events, -> { where(event: "update") }
26
- scope :destroyed_events, -> { where(event: "destroy") }
27
52
 
28
- # Deprecated short aliases kept for backwards compatibility
53
+ # Entries for +destroy+ events.
54
+ # @return [ActiveRecord::Relation]
55
+ scope :destroyed_events, -> { where(event: "destroy") }
29
56
 
57
+ # @deprecated Use {.created_events} instead.
30
58
  scope :creates, -> { created_events }
59
+ # @deprecated Use {.updated_events} instead.
31
60
  scope :updates, -> { updated_events }
61
+ # @deprecated Use {.destroyed_events} instead.
32
62
  scope :destroys, -> { destroyed_events }
33
63
 
34
- # Actor / resource scopes
64
+ # @!endgroup
65
+
66
+ # @!group Actor / resource scopes
67
+
68
+ # Entries written by a specific actor.
69
+ #
70
+ # @param actor [ActiveRecord::Base] the actor record to filter by
71
+ # @return [ActiveRecord::Relation]
72
+ # @example
73
+ # AuditLogEntry.by_actor(current_user)
35
74
  scope :by_actor, ->(actor) { where(actor_type: actor.class.name, actor_id: actor.id) }
75
+
76
+ # Entries for a specific resource class or instance.
77
+ # Pass a class to get all entries for that type; pass an instance for one record.
78
+ #
79
+ # @param resource [Class, ActiveRecord::Base]
80
+ # @return [ActiveRecord::Relation]
81
+ # @example All entries for Article
82
+ # AuditLogEntry.for_resource(Article)
83
+ # @example All entries for one article
84
+ # AuditLogEntry.for_resource(article)
36
85
  scope :for_resource, lambda { |resource|
37
86
  if resource.is_a?(Class)
38
87
  where(item_type: resource.name)
@@ -41,16 +90,61 @@ module RailsAuditLog
41
90
  end
42
91
  }
43
92
 
44
- # Time scopes
45
- scope :since, ->(time) { where(created_at: time..) }
46
- scope :until, ->(time) { where(created_at: ..time) }
93
+ # @!endgroup
94
+
95
+ # @!group Time scopes
96
+
97
+ # Entries created at or after +time+.
98
+ #
99
+ # @param time [Time]
100
+ # @return [ActiveRecord::Relation]
101
+ scope :since, ->(time) { where(created_at: time..) }
102
+
103
+ # Entries created at or before +time+.
104
+ #
105
+ # @param time [Time]
106
+ # @return [ActiveRecord::Relation]
107
+ scope :until, ->(time) { where(created_at: ..time) }
108
+
109
+ # Entries within a named period. Valid keys: <tt>"1h"</tt>, <tt>"24h"</tt>, <tt>"7d"</tt>.
110
+ #
111
+ # @param period [String] one of +PERIODS.keys+
112
+ # @return [ActiveRecord::Relation]
47
113
  scope :for_period, ->(period) { where(created_at: PERIODS[period].ago..) }
48
114
 
49
- # Projection scope — omits JSON blob columns for index/listing queries
115
+ # @!endgroup
116
+
117
+ # Omits the three JSON blob columns (+object_changes+, +object+, +metadata+)
118
+ # from the +SELECT+. Use on index/listing queries where blobs are not
119
+ # displayed to reduce I/O and avoid deserializing large payloads.
120
+ #
121
+ # @return [ActiveRecord::Relation]
50
122
  scope :slim, -> { select(column_names - BLOB_COLUMNS) }
51
123
 
52
- # Instance methods
124
+ # Entries where +object_changes+ contains a key matching +attribute+.
125
+ # Uses <tt>json_extract</tt> on SQLite/MySQL and <tt>->></tt> on PostgreSQL.
126
+ #
127
+ # @param attribute [Symbol, String] the attribute name to filter on
128
+ # @return [ActiveRecord::Relation]
129
+ # @example
130
+ # AuditLogEntry.touching(:title)
131
+ # post.audit_log_entries.updated_events.touching(:published_at)
132
+ scope :touching, ->(attribute) {
133
+ if connection.adapter_name =~ /PostgreSQL/i
134
+ # :nocov:
135
+ where("object_changes->>? IS NOT NULL", attribute.to_s)
136
+ # :nocov:
137
+ else
138
+ where("json_extract(object_changes, ?) IS NOT NULL", "$.#{attribute}")
139
+ end
140
+ }
53
141
 
142
+ # Reconstructs and returns the record's state *before* this entry's change.
143
+ # Uses the +object+ snapshot when available; falls back to deriving prior
144
+ # state from +object_changes+ (or the live record for +update+ entries).
145
+ #
146
+ # @return [ActiveRecord::Base, nil] an unpersisted instance; +nil+ for
147
+ # +create+ entries (there is no prior state)
54
148
  def reify
55
149
  return nil if event == "create"
56
150
 
@@ -82,18 +176,37 @@ module RailsAuditLog
82
176
  instance
83
177
  end
84
178
 
179
+ # Returns the entry immediately before this one in the version chain for
180
+ # the same record (lower +id+), or +nil+ if this is the first entry.
181
+ #
182
+ # @return [AuditLogEntry, nil]
85
183
  def previous
86
184
  self.class.where(item_type: item_type, item_id: item_id).where("id < ?", id).order(id: :desc).first
87
185
  end
88
186
 
187
+ # Returns the entry immediately after this one in the version chain for
188
+ # the same record (higher +id+), or +nil+ if this is the last entry.
189
+ #
190
+ # @return [AuditLogEntry, nil]
89
191
  def next
90
192
  self.class.where(item_type: item_type, item_id: item_id).where("id > ?", id).order(id: :asc).first
91
193
  end
92
194
 
195
+ # Returns the list of attribute (and association) names that changed in
196
+ # this entry, derived from the keys of +object_changes+.
197
+ #
198
+ # @return [Array<String>]
93
199
  def changed_attributes
94
200
  object_changes&.keys || []
95
201
  end
96
202
 
203
+ # Returns +object_changes+ in a named-key format convenient for display.
204
+ #
205
+ # @return [Hash{String => Hash}] keys are attribute names; values are
206
+ # hashes with +:from+ and +:to+ keys
207
+ # @example
208
+ # entry.diff
209
+ # # => { "title" => { from: "Old", to: "New" }, ... }
97
210
  def diff
98
211
  return {} unless object_changes
99
212
 
@@ -105,18 +218,5 @@ module RailsAuditLog
105
218
  def metadata_must_be_a_hash
106
219
  errors.add(:metadata, "must be a Hash") if metadata.present? && !metadata.is_a?(Hash)
107
220
  end
108
-
109
- public
110
-
111
- # Attribute scope — uses json_extract (SQLite/MySQL) or ->> (PostgreSQL)
112
- scope :touching, ->(attribute) {
113
- if connection.adapter_name =~ /PostgreSQL/i
114
- # :nocov:
115
- where("object_changes->>? IS NOT NULL", attribute.to_s)
116
- # :nocov:
117
- else
118
- where("json_extract(object_changes, ?) IS NOT NULL", "$.#{attribute}")
119
- end
120
- }
121
221
  end
122
222
  end
@@ -22,6 +22,11 @@ RailsAuditLog.configure do |config|
22
22
  # Per-model `audit_log version_limit: N` takes precedence.
23
23
  # config.version_limit = 100
24
24
 
25
+ # Global time-based TTL — entries older than this duration are pruned after
26
+ # each write. Composes with version_limit: an entry is removed when it
27
+ # exceeds either constraint. Default: nil (no TTL)
28
+ # config.retention_period = 90.days
29
+
25
30
  # Write all audit entries asynchronously via WriteAuditLogJob. Default: false
26
31
  # Per-model `audit_log async: true` also works.
27
32
  # config.async = true
@@ -0,0 +1,21 @@
1
+ require "rails/generators"
2
+ require "rails/generators/active_record"
3
+
4
+ module RailsAuditLog
5
+ module Generators
6
+ class MigrateFromPaperTrailGenerator < Rails::Generators::Base
7
+ include ActiveRecord::Generators::Migration
8
+
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ desc "Creates a data migration that copies PaperTrail versions to audit_log_entries."
12
+
13
+ def create_migration_file
14
+ migration_template(
15
+ "migrate_from_paper_trail.rb",
16
+ "db/migrate/migrate_from_paper_trail.rb"
17
+ )
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,99 @@
1
+ require "yaml"
2
+ require "json"
3
+
4
+ # Data migration: copies PaperTrail `versions` rows to `audit_log_entries`.
5
+ #
6
+ # Column mapping:
7
+ # versions.item_type → audit_log_entries.item_type
8
+ # versions.item_id → audit_log_entries.item_id
9
+ # versions.event → audit_log_entries.event (create/update/destroy only)
10
+ # versions.object_changes → audit_log_entries.object_changes (YAML or JSON → JSON)
11
+ # versions.object → audit_log_entries.object (YAML or JSON → JSON)
12
+ # versions.whodunnit → audit_log_entries.whodunnit_snapshot
13
+ # versions.created_at → audit_log_entries.created_at
14
+ #
15
+ # actor_type / actor_id are not populated — PaperTrail stores the actor
16
+ # only as a string (whodunnit), so polymorphic references cannot be inferred.
17
+ #
18
+ # This migration is irreversible.
19
+ class MigrateFromPaperTrail < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
20
+ BATCH_SIZE = 1_000
21
+ VALID_EVENTS = %w[create update destroy].freeze
22
+
23
+ # Isolated AR classes to avoid coupling to the host app's models.
24
+ class Version < ActiveRecord::Base # @api private
25
+ self.table_name = "versions"
26
+ end
27
+
28
+ class AuditEntry < ActiveRecord::Base # @api private
29
+ self.table_name = "audit_log_entries"
30
+ end
31
+
32
+ def up
33
+ unless table_exists?(:versions)
34
+ say " versions table not found — nothing to migrate."
35
+ return
36
+ end
37
+
38
+ total = Version.count
39
+ migrated = 0
40
+ skipped = 0
41
+
42
+ say_with_time "Migrating #{total} PaperTrail versions → audit_log_entries" do
43
+ Version.in_batches(of: BATCH_SIZE) do |batch|
44
+ rows = batch.filter_map do |v|
45
+ next unless VALID_EVENTS.include?(v.event)
46
+
47
+ {
48
+ event: v.event,
49
+ item_type: v.item_type,
50
+ item_id: v.item_id,
51
+ object_changes: parse_serialized(v.read_attribute_before_type_cast(:object_changes)),
52
+ object: parse_serialized(v.read_attribute_before_type_cast(:object)),
53
+ whodunnit_snapshot: v.whodunnit,
54
+ created_at: v.created_at,
55
+ updated_at: v.created_at
56
+ }
57
+ end
58
+
59
+ skipped += batch.size - rows.size
60
+ migrated += rows.size
61
+ AuditEntry.insert_all(rows) if rows.any?
62
+ end
63
+ end
64
+
65
+ say " Migrated: #{migrated} Skipped (unsupported event): #{skipped}"
66
+ end
67
+
68
+ def down
69
+ raise ActiveRecord::IrreversibleMigration,
70
+ "Cannot reverse PaperTrail migration — rows already written to audit_log_entries " \
71
+ "cannot be reliably distinguished from native entries."
72
+ end
73
+
74
+ private
75
+
76
+ # Parses a PaperTrail serialized column (YAML or JSON) and returns a Hash,
77
+ # or nil if the value is blank or unparseable.
78
+ def parse_serialized(value)
79
+ return nil if value.nil? || value.to_s.strip.empty?
80
+
81
+ begin
82
+ parsed = JSON.parse(value)
83
+ return parsed.is_a?(Hash) ? parsed : nil
84
+ rescue JSON::ParserError
85
+ # fall through to YAML
86
+ end
87
+
88
+ begin
89
+ parsed = YAML.safe_load(
90
+ value,
91
+ permitted_classes: [Symbol, Date, Time, DateTime, BigDecimal,
92
+ ActiveSupport::TimeWithZone, ActiveSupport::Duration]
93
+ )
94
+ parsed.is_a?(Hash) ? parsed : nil
95
+ rescue StandardError
96
+ nil
97
+ end
98
+ end
99
+ end
@@ -4,6 +4,7 @@ require "pagy"
4
4
  require "pagy/toolbox/paginators/method"
5
5
 
6
6
  module RailsAuditLog
7
+ # @api private
7
8
  class Engine < ::Rails::Engine
8
9
  isolate_namespace RailsAuditLog
9
10
 
@@ -1,24 +1,67 @@
1
1
  module RailsAuditLog
2
+ # RSpec matchers for asserting audit log behaviour.
3
+ #
4
+ # == Setup
5
+ #
6
+ # # spec/rails_helper.rb
7
+ # require "rails_audit_log/matchers"
8
+ #
9
+ # RSpec.configure do |config|
10
+ # config.include RailsAuditLog::Matchers
11
+ # end
12
+ #
13
+ # == Usage
14
+ #
15
+ # # Assert a record already has an entry
16
+ # expect(post).to have_audit_log_entry(:update).touching(:title)
17
+ #
18
+ # # Assert a block creates a new entry
19
+ # expect { post.update!(title: "New") }.to create_audit_log_entry(event: :update, touching: :title)
2
20
  module Matchers
21
+ # Returns a matcher that asserts the record has at least one matching
22
+ # {AuditLogEntry}.
23
+ #
24
+ # @param event [Symbol, String, nil] optional event filter
25
+ # (<tt>:create</tt>, <tt>:update</tt>, or <tt>:destroy</tt>)
26
+ # @return [HaveAuditLogEntry]
27
+ # @example
28
+ # expect(post).to have_audit_log_entry
29
+ # expect(post).to have_audit_log_entry(:update)
30
+ # expect(post).to have_audit_log_entry(:update).touching(:title)
3
31
  def have_audit_log_entry(event = nil)
4
32
  HaveAuditLogEntry.new(event)
5
33
  end
6
34
 
35
+ # Returns a matcher that asserts the block creates at least one new
36
+ # {AuditLogEntry} matching the given filters.
37
+ #
38
+ # @param event [Symbol, String, nil] optional event filter
39
+ # @param touching [Symbol, String, nil] optional attribute filter
40
+ # @return [CreateAuditLogEntry]
41
+ # @example
42
+ # expect { post.update!(title: "x") }.to create_audit_log_entry(event: :update)
43
+ # expect { post.update!(title: "x") }.to create_audit_log_entry(touching: :title)
7
44
  def create_audit_log_entry(event: nil, touching: nil)
8
45
  CreateAuditLogEntry.new(event: event, touching: touching)
9
46
  end
10
47
 
48
+ # RSpec matcher — asserts that a record already has a matching entry.
11
49
  class HaveAuditLogEntry
12
50
  def initialize(event)
13
51
  @event = event
14
52
  @touching = nil
15
53
  end
16
54
 
55
+ # Chains an attribute filter onto the matcher.
56
+ #
57
+ # @param attribute [Symbol, String]
58
+ # @return [self]
17
59
  def touching(attribute)
18
60
  @touching = attribute
19
61
  self
20
62
  end
21
63
 
64
+ # @api private
22
65
  def matches?(record)
23
66
  @record = record
24
67
  scope = record.audit_log_entries
@@ -27,14 +70,17 @@ module RailsAuditLog
27
70
  scope.exists?
28
71
  end
29
72
 
73
+ # @api private
30
74
  def failure_message
31
75
  "expected #{@record.class}##{@record.id} to have an audit log entry#{qualifier}"
32
76
  end
33
77
 
78
+ # @api private
34
79
  def failure_message_when_negated
35
80
  "expected #{@record.class}##{@record.id} not to have an audit log entry#{qualifier}"
36
81
  end
37
82
 
83
+ # @api private
38
84
  def description
39
85
  "have an audit log entry#{qualifier}"
40
86
  end
@@ -49,21 +95,28 @@ module RailsAuditLog
49
95
  end
50
96
  end
51
97
 
98
+ # RSpec matcher — asserts that a block creates a new matching entry.
52
99
  class CreateAuditLogEntry
53
100
  def initialize(event:, touching:)
54
101
  @event = event
55
102
  @touching = touching
56
103
  end
57
104
 
105
+ # Chains an attribute filter onto the matcher.
106
+ #
107
+ # @param attribute [Symbol, String]
108
+ # @return [self]
58
109
  def touching(attribute)
59
110
  @touching = attribute
60
111
  self
61
112
  end
62
113
 
114
+ # @api private
63
115
  def supports_block_expectations?
64
116
  true
65
117
  end
66
118
 
119
+ # @api private
67
120
  def matches?(block)
68
121
  @before = matching_scope.count
69
122
  block.call
@@ -71,14 +124,17 @@ module RailsAuditLog
71
124
  @after > @before
72
125
  end
73
126
 
127
+ # @api private
74
128
  def failure_message
75
129
  "expected block to create an audit log entry#{qualifier}, but none was created"
76
130
  end
77
131
 
132
+ # @api private
78
133
  def failure_message_when_negated
79
134
  "expected block not to create an audit log entry#{qualifier}, but one was created"
80
135
  end
81
136
 
137
+ # @api private
82
138
  def description
83
139
  "create an audit log entry#{qualifier}"
84
140
  end