diff --git a/features/matchers/have_reported_event_matcher.feature b/features/matchers/have_reported_event_matcher.feature new file mode 100644 index 0000000000..e6406f8317 --- /dev/null +++ b/features/matchers/have_reported_event_matcher.feature @@ -0,0 +1,167 @@ +Feature: `have_reported_event` matcher + + The `have_reported_event` matcher is used to check if an event was reported + via Rails' EventReporter (Rails 8.1+). + + Background: + Given event reporter is available + + Scenario: Checking event name + Given a file named "spec/models/user_spec.rb" with: + """ruby + require "rails_helper" + + RSpec.describe "event reporting" do + it "matches with event name" do + expect { + Rails.event.notify("user.created", { id: 123 }) + }.to have_reported_event("user.created") + end + end + """ + When I run `rspec spec/models/user_spec.rb` + Then the examples should all pass + + Scenario: Checking event payload + Given a file named "spec/models/user_spec.rb" with: + """ruby + require "rails_helper" + + RSpec.describe "event reporting" do + it "matches with payload" do + expect { + Rails.event.notify("user.created", { id: 123, name: "John" }) + }.to have_reported_event("user.created").with_payload(id: 123) + end + end + """ + When I run `rspec spec/models/user_spec.rb` + Then the examples should all pass + + Scenario: Checking event tags + Given a file named "spec/models/user_spec.rb" with: + """ruby + require "rails_helper" + + RSpec.describe "event reporting" do + it "matches with tags" do + expect { + Rails.event.tagged(source: "api") do + Rails.event.notify("user.created", { id: 123 }) + end + }.to have_reported_event("user.created").with_tags(source: "api") + end + end + """ + When I run `rspec spec/models/user_spec.rb` + Then the examples should all pass + + Scenario: Checking event context + Given a file named "spec/models/user_spec.rb" with: + """ruby + require "rails_helper" + + RSpec.describe "event reporting" do + it "matches with context" do + Rails.event.set_context(request_id: "abc123") + expect { + Rails.event.notify("user.created", { id: 123 }) + }.to have_reported_event("user.created").with_context(request_id: "abc123") + Rails.event.clear_context + end + end + """ + When I run `rspec spec/models/user_spec.rb` + Then the examples should all pass + + Scenario: Checking event with class-based event object + Given a file named "app/events/user_created_event.rb" with: + """ruby + class UserCreatedEvent + attr_reader :id, :name + + def initialize(id:, name:) + @id = id + @name = name + end + + def serialize + { id: @id, name: @name } + end + end + """ + And a file named "spec/models/user_spec.rb" with: + """ruby + require "rails_helper" + + RSpec.describe "event reporting" do + it "matches with event class" do + event = UserCreatedEvent.new(id: 123, name: "John") + expect { + Rails.event.notify(event) + }.to have_reported_event(UserCreatedEvent).with_payload(id: 123) + end + end + """ + When I run `rspec spec/models/user_spec.rb` + Then the examples should all pass + + Scenario: Using `have_reported_events` for multiple events + Given a file named "spec/models/user_spec.rb" with: + """ruby + require "rails_helper" + + RSpec.describe "event reporting" do + it "matches multiple events regardless of order" do + expect { + Rails.event.notify("user.created", { id: 123 }) + Rails.event.notify("email.sent", { to: "john@example.com" }) + }.to have_reported_events([ + { name: "email.sent" }, + { name: "user.created" } + ]) + end + end + """ + When I run `rspec spec/models/user_spec.rb` + Then the examples should all pass + + Scenario: Using `have_reported_no_event` to check no events reported + Given a file named "spec/models/user_spec.rb" with: + """ruby + require "rails_helper" + + RSpec.describe "event reporting" do + it "passes when no events are reported" do + expect { + # no events + }.to have_reported_no_event + end + + it "passes when specific event is not reported" do + expect { + Rails.event.notify("user.updated", { id: 123 }) + }.to have_reported_no_event("user.created") + end + end + """ + When I run `rspec spec/models/user_spec.rb` + Then the examples should all pass + + Scenario: Using `with_debug_event_reporting` for debug events + Given a file named "spec/models/user_spec.rb" with: + """ruby + require "rails_helper" + + RSpec.describe "event reporting" do + it "captures debug events within the block" do + with_debug_event_reporting do + expect { + Rails.event.notify("debug.trace", { step: 1 }) + }.to have_reported_event("debug.trace") + end + end + end + """ + When I run `rspec spec/models/user_spec.rb` + Then the examples should all pass diff --git a/features/step_definitions/additional_cli_steps.rb b/features/step_definitions/additional_cli_steps.rb index 839bd145b7..cf3fe201e7 100644 --- a/features/step_definitions/additional_cli_steps.rb +++ b/features/step_definitions/additional_cli_steps.rb @@ -43,3 +43,9 @@ pending "Action Mailbox is not available" end end + +Given /event reporter is available/ do + unless RSpec::Rails::FeatureCheck.has_event_reporter? + pending "Event Reporter is not available" + end +end diff --git a/lib/rspec/rails/feature_check.rb b/lib/rspec/rails/feature_check.rb index a19b2cfef2..75d199a1c8 100644 --- a/lib/rspec/rails/feature_check.rb +++ b/lib/rspec/rails/feature_check.rb @@ -43,6 +43,10 @@ def has_action_mailbox? defined?(::ActionMailbox) end + def has_event_reporter? + defined?(::ActiveSupport::EventReporter) + end + def type_metatag(type) "type: :#{type}" end diff --git a/lib/rspec/rails/matchers.rb b/lib/rspec/rails/matchers.rb index fb297eabc8..0a30f64d82 100644 --- a/lib/rspec/rails/matchers.rb +++ b/lib/rspec/rails/matchers.rb @@ -34,3 +34,7 @@ module Matchers if RSpec::Rails::FeatureCheck.has_action_mailbox? require 'rspec/rails/matchers/action_mailbox' end + +if RSpec::Rails::FeatureCheck.has_event_reporter? + require 'rspec/rails/matchers/event_reporter' +end diff --git a/lib/rspec/rails/matchers/event_reporter.rb b/lib/rspec/rails/matchers/event_reporter.rb new file mode 100644 index 0000000000..a1a3b290db --- /dev/null +++ b/lib/rspec/rails/matchers/event_reporter.rb @@ -0,0 +1,558 @@ +# frozen_string_literal: true + +module RSpec + module Rails + module Matchers + # Container module for event reporter matchers. + # + # @api private + module EventReporter + # @api private + # Internal subscriber that collects events during test execution. + module EventCollector + @subscribed = false + @mutex = Mutex.new + + class << self + # @api private + # Receives events from the Rails EventReporter subscriber. + def emit(event) + event_recorders&.each do |recorder| + recorder << Event.new(event) + end + true + end + + # @api private + # Records events emitted during the block execution. + def record + subscribe + events = [] + event_recorders << events + begin + yield + events + ensure + event_recorders.delete_if { |r| events.equal?(r) } + end + end + + private + + def subscribe + return if @subscribed + + @mutex.synchronize do + unless @subscribed + if ActiveSupport.event_reporter + ActiveSupport.event_reporter.subscribe(self) + @subscribed = true + else + raise "No event reporter is configured. Ensure Rails.application is initialized." + end + end + end + end + + def event_recorders + ActiveSupport::IsolatedExecutionState[:rspec_rails_event_reporter_events] ||= [] + end + end + end + + # @api private + # Wraps event data and provides matching logic. + class Event + # @api private + # Returns the raw event data hash. + attr_reader :event_data + + def initialize(event_data) + @event_data = event_data + end + + # @api private + # Returns a human-readable representation of the event. + def inspect + "#{event_data[:name]} (payload: #{event_data[:payload].inspect}, tags: #{event_data[:tags].inspect}, context: #{event_data[:context].inspect})" + end + + def matches?(name, payload = nil, tags = nil, context = nil) + return false if name && resolve_name(name) != event_data[:name] + return false if payload && !matches_payload?(payload) + return false if tags && !matches_tags?(tags) + return false if context && !matches_context?(context) + + true + end + + private + + def resolve_name(name) + case name + when String, Symbol + name.to_s + when Class + name.name + else + name.class.name + end + end + + def matches_payload?(expected_payload) + matches_hash?(expected_payload, :payload, allow_regexp: true) + end + + def matches_tags?(expected_tags) + matches_hash?(expected_tags, :tags, allow_regexp: true) + end + + def matches_context?(expected_context) + matches_hash?(expected_context, :context, allow_regexp: true) + end + + def matches_hash?(expected, key, allow_regexp:) + actual = normalize_to_hash(event_data[key]) + return false unless actual.is_a?(Hash) + + expected.all? do |k, v| + return false unless actual.key?(k) + + actual_value = actual[k] + if allow_regexp && v.is_a?(Regexp) + actual_value.to_s.match?(v) + else + actual_value == v + end + end + end + + def normalize_to_hash(value) + if value.respond_to?(:serialize) + value.serialize + else + value + end + end + end + + # @api private + # Base class for event reporter matchers. + class Base < RSpec::Rails::Matchers::BaseMatcher + def initialize + super() + @expected_payload = nil + @expected_tags = nil + @expected_context = nil + end + + def supports_value_expectations? + false + end + + def supports_block_expectations? + true + end + + # @api public + # Specifies the expected payload. + # + # @param payload [Hash] expected payload keys and values + # @return [self] self for chaining + # @raise [ArgumentError] if payload is not a Hash + def with_payload(payload) + require_hash_argument(payload, :with_payload) + @expected_payload = payload + self + end + + # @api public + # Specifies the expected tags (supports Regexp values for matching). + # + # @param tags [Hash] expected tag keys and values (values can be Regexp) + # @return [self] self for chaining + # @raise [ArgumentError] if tags is not a Hash + def with_tags(tags) + require_hash_argument(tags, :with_tags) + @expected_tags = tags + self + end + + # @api public + # Specifies the expected context + # + # @param context [Hash] expected context keys and values (values can be regex) + # @return [self] self for chaining + # @raise [ArgumentError] if context is not a Hash + def with_context(context) + require_hash_argument(context, :with_context) + @expected_context = context + self + end + + private + + def require_hash_argument(value, method_name) + return if value.is_a?(Hash) + + raise ArgumentError, "#{method_name} requires a Hash, got #{value.class}" + end + + def formatted_events + @events.map { |e| " #{e.inspect}" }.join("\n") + end + + def format_event_criteria(name: nil, payload: nil, tags: nil, context: nil) + parts = [] + parts << "name: #{name.inspect}" if name + parts << "payload: #{payload.inspect}" if payload + parts << "tags: #{tags.inspect}" if tags + parts << "context: #{context.inspect}" if context + parts.join(", ") + end + + def find_matching_event(name: @expected_name, payload: @expected_payload, tags: @expected_tags, context: @expected_context) + @events.find { |event| event.matches?(name, payload, tags, context) } + end + end + + # @api private + # + # Matcher class for `have_reported_event`. Should not be instantiated directly. + # + # @see RSpec::Rails::Matchers#have_reported_event + class HaveReportedEvent < Base + def initialize(expected_name) + super() + @expected_name = expected_name + end + + def matches?(block) + @events = EventCollector.record(&block) + + if @events.empty? + @failure_reason = :no_events + return false + end + + @matching_event = find_matching_event + + if @matching_event + true + else + @failure_reason = :no_match + false + end + end + + # @api private + # Returns the failure message when the expectation is not met. + def failure_message + case @failure_reason + when :no_events + "expected an event to be reported, but there were no events reported" + when :no_match + <<~MSG.chomp + expected an event to be reported matching: + #{expectation_details} + but none of the #{@events.size} reported events matched: + #{formatted_events} + MSG + end + end + + # @api private + # Returns the failure message when the negated expectation is not met. + def failure_message_when_negated + if @expected_name + "expected no event matching #{@expected_name.inspect} to be reported, but one was found" + else + "expected no event to be reported, but one was found" + end + end + + # @api private + # Returns a description of the matcher. + def description + desc = "report event" + desc += " #{@expected_name.inspect}" if @expected_name + desc += " with payload #{@expected_payload.inspect}" if @expected_payload + desc += " with tags #{@expected_tags.inspect}" if @expected_tags + desc += " with context #{@expected_context.inspect}" if @expected_context + desc + end + + private + + def expectation_details + details = [] + details << " name: #{@expected_name.inspect}" if @expected_name + details << " payload: #{@expected_payload.inspect}" if @expected_payload + details << " tags: #{@expected_tags.inspect}" if @expected_tags + details << " context: #{@expected_context.inspect}" if @expected_context + details.join("\n") + end + end + + # @api private + # + # Matcher class for `have_reported_no_event`. Should not be instantiated directly. + # + # @see RSpec::Rails::Matchers#have_reported_no_event + class HaveReportedNoEvent < Base + def initialize(expected_name = nil) + super() + @expected_name = expected_name + end + + def matches?(block) + @events = EventCollector.record(&block) + + if has_filters? + @matching_event = find_matching_event + @matching_event.nil? + else + @events.empty? + end + end + + # @api private + # Returns the failure message when the expectation is not met. + def failure_message + if has_filters? + <<~MSG.chomp + expected no event matching #{match_description} to be reported, but found: + #{@matching_event.inspect} + MSG + else + <<~MSG.chomp + expected no events to be reported, but #{@events.size} events were reported: + #{formatted_events} + MSG + end + end + + # @api private + # Returns the failure message when the negated expectation is not met. + def failure_message_when_negated + if has_filters? + "expected an event matching #{match_description} to be reported, but none were found" + else + "expected at least one event to be reported, but none were" + end + end + + # @api private + # Returns a description of the matcher. + def description + if has_filters? + "report no event matching #{match_description}" + else + "report no events" + end + end + + private + + def has_filters? + !!(@expected_name || @expected_payload || @expected_tags || @expected_context) + end + + def match_description + format_event_criteria( + name: @expected_name, + payload: @expected_payload, + tags: @expected_tags, + context: @expected_context + ) + end + end + + # @api private + # + # Matcher class for `have_reported_events`. Should not be instantiated directly. + # + # @see RSpec::Rails::Matchers#have_reported_events + class HaveReportedEvents < Base + def initialize(expected_events) + super() + @expected_events = expected_events + end + + def matches?(block) + @events = EventCollector.record(&block) + + @missing_events = find_missing_events + + if @missing_events.empty? + true + elsif @events.empty? + @failure_reason = :no_events + false + else + @failure_reason = :missing_events + false + end + end + + # @api private + # Returns the failure message when the expectation is not met. + def failure_message + case @failure_reason + when :no_events + "expected #{@expected_events.size} events to be reported, but there were no events reported" + when :missing_events + <<~MSG.chomp + expected all events to be reported, but some were missing: + #{formatted_missing_events} + reported events: + #{formatted_events} + MSG + end + end + + # @api private + # Returns the failure message when the negated expectation is not met. + def failure_message_when_negated + "expected events not to be reported, but all were found" + end + + # @api private + # Returns a description of the matcher. + def description + "report #{@expected_events.size} events" + end + + private + + def find_missing_events + remaining_events = @events.dup + missing = [] + + @expected_events.each do |expected| + match_index = remaining_events.find_index do |event| + event.matches?(expected[:name], expected[:payload], expected[:tags], expected[:context]) + end + + if match_index + remaining_events.delete_at(match_index) + else + missing << expected + end + end + + missing + end + + def formatted_missing_events + @missing_events.map do |e| + " #{format_event_criteria(name: e[:name], payload: e[:payload], tags: e[:tags], context: e[:context])}" + end.join("\n") + end + end + end + + # @api public + # Passes if the block reports an event matching the expected name. + # + # @example Basic usage + # expect { Rails.event.notify("user.created", { id: 123 }) } + # .to have_reported_event("user.created") + # + # @example With payload matching + # expect { Rails.event.notify("user.created", { id: 123, name: "John" }) } + # .to have_reported_event("user.created") + # .with_payload(id: 123) + # + # @example With tags matching (supports Regexp) + # expect { + # Rails.event.tagged(request_id: "abc123") do + # Rails.event.notify("user.created", { id: 123 }) + # end + # }.to have_reported_event("user.created") + # .with_tags(request_id: /[a-z0-9]+/) + # + # @example With context matching + # Rails.event.set_context(request_id: "abc123") + # expect { + # Rails.event.notify("user.created", { id: 123 }) + # }.to have_reported_event("user.created") + # .with_context(request_id: /[a-z0-9]+/) + # + # @param name [String, Symbol] the expected event name + # @return [HaveReportedEvent] + def have_reported_event(name = nil) + EventReporter::HaveReportedEvent.new(name) + end + + # @api public + # Passes if the block reports no events (or no events matching the criteria). + # + # @example Basic usage - no events at all + # expect { }.to have_reported_no_event + # + # @example With specific event name + # expect { Rails.event.notify("other.event", {}) } + # .to have_reported_no_event("user.created") + # + # @example With payload filtering + # expect { Rails.event.notify("user.created", { id: 456 }) } + # .to have_reported_no_event("user.created") + # .with_payload(id: 123) + # + # @param name [String, Symbol, nil] the event name to filter (optional) + # @return [HaveReportedNoEvent] + def have_reported_no_event(name = nil) + EventReporter::HaveReportedNoEvent.new(name) + end + + # @api public + # Passes if the block reports all specified events (order-agnostic). + # + # @example Basic usage + # expect { + # Rails.event.notify("user.created", { id: 123 }) + # Rails.event.notify("email.sent", { to: "user@example.com" }) + # }.to have_reported_events([ + # { name: "user.created", payload: { id: 123 } }, + # { name: "email.sent" } + # ]) + # + # @example With tags matching (supports Regexp) + # expect { + # Rails.event.tagged(request_id: "123") do + # Rails.event.notify("user.created", { id: 123 }) + # Rails.event.notify("email.sent", { to: "user@example.com" }) + # end + # }.to have_reported_events([ + # { name: "user.created", tags: { request_id: /\d+/ } }, + # { name: "email.sent" } + # ]) + # + # @param expected_events [Array] array of expected event specifications + # Each hash can have :name, :payload, and :tags keys + # @return [HaveReportedEvents] + def have_reported_events(expected_events) + EventReporter::HaveReportedEvents.new(expected_events) + end + + # @api public + # Temporarily enables debug mode for the event reporter within the block. + # This allows debug events (reported via `Rails.event.debug`) to be captured + # and tested. + # + # @example Testing debug events + # with_debug_event_reporting do + # expect { + # Rails.event.debug("debug.info", { data: "test" }) + # }.to have_reported_event("debug.info") + # end + # + # @yield The block within which debug mode is enabled + # @return [Object] the result of the block + def with_debug_event_reporting(&block) + ActiveSupport.event_reporter.with_debug(&block) + end + end + end +end diff --git a/spec/rspec/rails/matchers/event_reporter_spec.rb b/spec/rspec/rails/matchers/event_reporter_spec.rb new file mode 100644 index 0000000000..dd21ab9930 --- /dev/null +++ b/spec/rspec/rails/matchers/event_reporter_spec.rb @@ -0,0 +1,487 @@ +module TestEvents + class UserCreated + attr_reader :id, :name + + def initialize(id:, name:) + @id = id + @name = name + end + + def serialize + { id: @id, name: @name } + end + end +end + +RSpec.describe "have_reported_event", skip: !RSpec::Rails::FeatureCheck.has_event_reporter? do + describe "without name matching" do + it "passes when any event is reported" do + expect { Rails.event.notify("user.created", { id: 123 }) }.to have_reported_event + end + + it "fails when no events are reported" do + expect { + expect { }.to have_reported_event + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /no events reported/) + end + end + + describe "basic name matching" do + it "passes when event is reported" do + expect { Rails.event.notify("user.created", { id: 123 }) }.to have_reported_event("user.created") + end + + it "passes with symbol event name" do + expect { Rails.event.notify(:user_created, { id: 123 }) }.to have_reported_event("user_created") + end + + it "passes with class-based event name" do + event = TestEvents::UserCreated.new(id: 123, name: "John") + expect { + Rails.event.notify(event) + }.to have_reported_event(TestEvents::UserCreated) + end + + it "passes with event object instance as name parameter" do + event = TestEvents::UserCreated.new(id: 123, name: "John") + expect { + Rails.event.notify(event) + }.to have_reported_event(event) + end + + it "fails when no events are reported" do + expect { + expect { }.to have_reported_event("user.created") + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /no events reported/) + end + + it "fails when event name doesn't match" do + expect { + expect { + Rails.event.notify("user.updated", { id: 123 }) + }.to have_reported_event("user.created") + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /none of the 1 reported events matched/) + end + end + + describe "with payload matching" do + it "passes with matching payload" do + expect { + Rails.event.notify("user.created", { id: 123, name: "John" }) + }.to have_reported_event("user.created").with_payload(id: 123) + end + + it "passes with partial payload matching" do + expect { + Rails.event.notify("user.created", { id: 123, name: "John", email: "john@example.com" }) + }.to have_reported_event("user.created").with_payload(id: 123, name: "John") + end + + it "passes with regex payload matching" do + expect { + Rails.event.notify("user.created", { id: 123, email: "john@example.com" }) + }.to have_reported_event("user.created").with_payload(email: /@example\.com$/) + end + + it "passes with event object payload via serialize" do + event = TestEvents::UserCreated.new(id: 123, name: "John") + expect { + Rails.event.notify(event) + }.to have_reported_event(TestEvents::UserCreated).with_payload(id: 123) + end + + it "fails when payload doesn't match" do + expect { + expect { + Rails.event.notify("user.created", { id: 456 }) + }.to have_reported_event("user.created").with_payload(id: 123) + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /none of the 1 reported events matched/) + end + + it "fails when event payload is nil" do + expect { + expect { + Rails.event.notify("user.created", nil) + }.to have_reported_event("user.created").with_payload(id: 123) + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /none of the 1 reported events matched/) + end + + it "raises ArgumentError when with_payload is called with non-Hash" do + expect { + have_reported_event("user.created").with_payload("invalid") + }.to raise_error(ArgumentError, /with_payload requires a Hash/) + end + end + + describe "with tags matching" do + it "passes with matching tags" do + expect { + Rails.event.tagged(request_id: "abc123") do + Rails.event.notify("user.created", { id: 123 }) + end + }.to have_reported_event("user.created").with_tags(request_id: "abc123") + end + + it "passes with regex tag matching" do + expect { + Rails.event.tagged(request_id: "abc123") do + Rails.event.notify("user.created", { id: 123 }) + end + }.to have_reported_event("user.created").with_tags(request_id: /[a-z0-9]+/) + end + + it "passes with partial tag matching" do + expect { + Rails.event.tagged(request_id: "abc123", user_id: 456) do + Rails.event.notify("user.created", { id: 123 }) + end + }.to have_reported_event("user.created").with_tags(request_id: "abc123") + end + + it "fails when tags don't match" do + expect { + expect { + Rails.event.tagged(request_id: "xyz") do + Rails.event.notify("user.created", { id: 123 }) + end + }.to have_reported_event("user.created").with_tags(request_id: "abc123") + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /none of the 1 reported events matched/) + end + + it "fails when event has no tags" do + expect { + expect { + Rails.event.notify("user.created", { id: 123 }) + }.to have_reported_event("user.created").with_tags(request_id: "abc123") + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /none of the 1 reported events matched/) + end + + it "fails when expected tag key is missing" do + expect { + expect { + Rails.event.tagged(other_key: "value") do + Rails.event.notify("user.created", { id: 123 }) + end + }.to have_reported_event("user.created").with_tags(request_id: /.*/) + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /none of the 1 reported events matched/) + end + + it "raises ArgumentError when with_tags is called with non-Hash" do + expect { + have_reported_event("user.created").with_tags("invalid") + }.to raise_error(ArgumentError, /with_tags requires a Hash/) + end + end + + describe "with context matching" do + around do |example| + example.run + ensure + Rails.event.clear_context + end + + it "passes with matching context" do + Rails.event.set_context(request_id: "abc123") + expect { + Rails.event.notify("user.created", { id: 123 }) + }.to have_reported_event("user.created").with_context(request_id: "abc123") + end + + it "passes with regex context matching" do + Rails.event.set_context(request_id: "abc123") + expect { + Rails.event.notify("user.created", { id: 123 }) + }.to have_reported_event("user.created").with_context(request_id: /[a-z0-9]+/) + end + + it "passes with partial context matching" do + Rails.event.set_context(request_id: "abc123", user_id: 456) + expect { + Rails.event.notify("user.created", { id: 123 }) + }.to have_reported_event("user.created").with_context(request_id: "abc123") + end + + it "fails when context doesn't match" do + Rails.event.set_context(request_id: "xyz") + expect { + expect { + Rails.event.notify("user.created", { id: 123 }) + }.to have_reported_event("user.created").with_context(request_id: "abc123") + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /none of the 1 reported events matched/) + end + + it "fails when event has no context" do + expect { + expect { + Rails.event.notify("user.created", { id: 123 }) + }.to have_reported_event("user.created").with_context(request_id: "abc123") + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /none of the 1 reported events matched/) + end + + it "raises ArgumentError when with_context is called with non-Hash" do + expect { + have_reported_event("user.created").with_context("invalid") + }.to raise_error(ArgumentError, /with_context requires a Hash/) + end + end + + describe "negation" do + it "passes when event is not reported" do + expect { + Rails.event.notify("user.updated", { id: 123 }) + }.not_to have_reported_event("user.created") + end + + it "passes when no events are reported" do + expect { }.not_to have_reported_event("user.created") + end + + it "fails when event is reported" do + expect { + expect { + Rails.event.notify("user.created", { id: 123 }) + }.not_to have_reported_event("user.created") + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /expected no event matching "user.created" to be reported/) + end + + it "fails when any event is reported and no name specified" do + expect { + expect { + Rails.event.notify("user.created", { id: 123 }) + }.not_to have_reported_event + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /expected no event to be reported, but one was found/) + end + end +end + +RSpec.describe "have_reported_no_event", skip: !RSpec::Rails::FeatureCheck.has_event_reporter? do + describe "without filters" do + it "passes when no events are reported" do + expect { }.to have_reported_no_event + end + + it "fails when any event is reported" do + expect { + expect { + Rails.event.notify("user.created", { id: 123 }) + }.to have_reported_no_event + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /expected no events to be reported/) + end + end + + describe "with name filter" do + it "passes when specific event is not reported" do + expect { + Rails.event.notify("user.updated", { id: 123 }) + }.to have_reported_no_event("user.created") + end + + it "fails when specific event is reported" do + expect { + expect { + Rails.event.notify("user.created", { id: 123 }) + }.to have_reported_no_event("user.created") + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /expected no event matching name: "user.created" to be reported/) + end + end + + describe "with payload filter" do + it "passes when no event matches the payload" do + expect { + Rails.event.notify("user.created", { id: 456 }) + }.to have_reported_no_event("user.created").with_payload(id: 123) + end + + it "fails when event matches the payload" do + expect { + expect { + Rails.event.notify("user.created", { id: 123 }) + }.to have_reported_no_event("user.created").with_payload(id: 123) + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /expected no event matching/) + end + + it "raises ArgumentError when with_payload is called with non-Hash" do + expect { + have_reported_no_event("user.created").with_payload("invalid") + }.to raise_error(ArgumentError, /with_payload requires a Hash/) + end + end + + describe "with tags filter" do + it "passes when no event matches the tags" do + expect { + Rails.event.tagged(request_id: "xyz") do + Rails.event.notify("user.created", { id: 123 }) + end + }.to have_reported_no_event("user.created").with_tags(request_id: "abc") + end + + it "fails when event matches the tags" do + expect { + expect { + Rails.event.tagged(request_id: "abc") do + Rails.event.notify("user.created", { id: 123 }) + end + }.to have_reported_no_event("user.created").with_tags(request_id: "abc") + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /expected no event matching/) + end + + it "raises ArgumentError when with_tags is called with non-Hash" do + expect { + have_reported_no_event("user.created").with_tags("invalid") + }.to raise_error(ArgumentError, /with_tags requires a Hash/) + end + end + + describe "negation" do + it "passes when events are reported" do + expect { + Rails.event.notify("user.created", { id: 123 }) + }.not_to have_reported_no_event + end + + it "fails when no events are reported" do + expect { + expect { }.not_to have_reported_no_event + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /expected at least one event to be reported/) + end + + it "passes when matching event is reported (with name filter)" do + expect { + Rails.event.notify("user.created", { id: 123 }) + }.not_to have_reported_no_event("user.created") + end + + it "fails when no matching event is reported (with name filter)" do + expect { + expect { + Rails.event.notify("user.updated", { id: 123 }) + }.not_to have_reported_no_event("user.created") + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /expected an event matching name: "user.created" to be reported/) + end + end +end + +RSpec.describe "have_reported_events", skip: !RSpec::Rails::FeatureCheck.has_event_reporter? do + describe "basic matching" do + it "passes when no events expected and none reported" do + expect { }.to have_reported_events([]) + end + + it "passes when all events are reported" do + expect { + Rails.event.notify("user.created", { id: 123 }) + Rails.event.notify("email.sent", { to: "user@example.com" }) + }.to have_reported_events([ + { name: "user.created", payload: { id: 123 } }, + { name: "email.sent" } + ]) + end + + it "passes regardless of order" do + expect { + Rails.event.notify("email.sent", { to: "user@example.com" }) + Rails.event.notify("user.created", { id: 123 }) + }.to have_reported_events([ + { name: "user.created", payload: { id: 123 } }, + { name: "email.sent" } + ]) + end + + it "fails when no events are reported" do + expect { + expect { }.to have_reported_events([ + { name: "user.created" } + ]) + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /no events reported/) + end + + it "fails when some events are missing" do + expect { + expect { + Rails.event.notify("user.created", { id: 123 }) + }.to have_reported_events([ + { name: "user.created" }, + { name: "email.sent" } + ]) + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /some were missing/) + end + end + + describe "with tags matching" do + it "supports tag matching with regex" do + expect { + Rails.event.tagged(request_id: "123") do + Rails.event.notify("user.created", { id: 123 }) + Rails.event.notify("email.sent", { to: "user@example.com" }) + end + }.to have_reported_events([ + { name: "user.created", tags: { request_id: /\d+/ } }, + { name: "email.sent" } + ]) + end + end + + describe "negation" do + it "passes when not all events are reported" do + expect { + Rails.event.notify("user.created", { id: 123 }) + }.not_to have_reported_events([ + { name: "user.created" }, + { name: "email.sent" } + ]) + end + + it "fails when all events are reported" do + expect { + expect { + Rails.event.notify("user.created", { id: 123 }) + Rails.event.notify("email.sent", { to: "user@example.com" }) + }.not_to have_reported_events([ + { name: "user.created" }, + { name: "email.sent" } + ]) + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /expected events not to be reported, but all were found/) + end + end +end + +RSpec.describe "with_debug_event_reporting", skip: !RSpec::Rails::FeatureCheck.has_event_reporter? do + around do |example| + original_debug_mode = ActiveSupport.event_reporter.debug_mode? + example.run + ActiveSupport.event_reporter.debug_mode = original_debug_mode + end + + it "enables debug events within the block" do + with_debug_event_reporting do + expect { + Rails.event.debug("debug.event", { data: "test" }) + }.to have_reported_event("debug.event") + end + end + + it "does not report debug events when debug_mode is disabled" do + ActiveSupport.event_reporter.debug_mode = false + expect { + Rails.event.debug("debug.event", { data: "test" }) + }.to have_reported_no_event("debug.event") + end + + it "reports debug events when debug_mode is enabled via with_debug_event_reporting" do + ActiveSupport.event_reporter.debug_mode = false + with_debug_event_reporting do + expect { + Rails.event.debug("debug.event", { data: "test" }) + }.to have_reported_event("debug.event") + end + end + + it "restores original debug_mode after the block" do + ActiveSupport.event_reporter.debug_mode = false + with_debug_event_reporting do + expect(ActiveSupport.event_reporter.debug_mode?).to be_truthy + end + expect(ActiveSupport.event_reporter.debug_mode?).to be_falsey + end +end