lib/rspec/rails/matchers/action_cable/have_broadcasted_to.rb



module RSpec
  module Rails
    module Matchers
      module ActionCable
        # rubocop: disable Metrics/ClassLength
        # @private
        class HaveBroadcastedTo < RSpec::Matchers::BuiltIn::BaseMatcher
          def initialize(target, channel:)
            @target = target
            @channel = channel
            @block = proc { }
            @data = nil
            set_expected_number(:exactly, 1)
          end

          def with(data = nil, &block)
            @data = data
            @data = @data.with_indifferent_access if @data.is_a?(Hash)
            @block = block if block
            self
          end

          def exactly(count)
            set_expected_number(:exactly, count)
            self
          end

          def at_least(count)
            set_expected_number(:at_least, count)
            self
          end

          def at_most(count)
            set_expected_number(:at_most, count)
            self
          end

          def times
            self
          end

          def once
            exactly(:once)
          end

          def twice
            exactly(:twice)
          end

          def thrice
            exactly(:thrice)
          end

          def failure_message
            "expected to broadcast #{base_message}".tap do |msg|
              if @unmatching_msgs.any?
                msg << "\nBroadcasted messages to #{stream}:"
                @unmatching_msgs.each do |data|
                  msg << "\n   #{data}"
                end
              end
            end
          end

          def failure_message_when_negated
            "expected not to broadcast #{base_message}"
          end

          def message_expectation_modifier
            case @expectation_type
            when :exactly then "exactly"
            when :at_most then "at most"
            when :at_least then "at least"
            end
          end

          def supports_block_expectations?
            true
          end

          def matches?(proc)
            raise ArgumentError, "have_broadcasted_to and broadcast_to only support block expectations" unless Proc === proc

            original_sent_messages_count = pubsub_adapter.broadcasts(stream).size
            proc.call
            in_block_messages = pubsub_adapter.broadcasts(stream).drop(original_sent_messages_count)

            check(in_block_messages)
          end

          def from_channel(channel)
            @channel = channel
            self
          end

        private

          def stream
            @stream ||= if @target.is_a?(String)
                          @target
                        else
                          check_channel_presence
                          @channel.broadcasting_for(@target)
                        end
          end

          def check(messages)
            @matching_msgs, @unmatching_msgs = messages.partition do |msg|
              decoded = ActiveSupport::JSON.decode(msg)
              decoded = decoded.with_indifferent_access if decoded.is_a?(Hash)

              if @data.nil? || @data === decoded
                @block.call(decoded)
                true
              else
                false
              end
            end

            @matching_msgs_count = @matching_msgs.size

            case @expectation_type
            when :exactly then @expected_number == @matching_msgs_count
            when :at_most then @expected_number >= @matching_msgs_count
            when :at_least then @expected_number <= @matching_msgs_count
            end
          end

          def set_expected_number(relativity, count)
            @expectation_type = relativity
            @expected_number =
              case count
              when :once then 1
              when :twice then 2
              when :thrice then 3
              else Integer(count)
              end
          end

          def base_message
            "#{message_expectation_modifier} #{@expected_number} messages to #{stream}".tap do |msg|
              msg << " with #{data_description(@data)}" unless @data.nil?
              msg << ", but broadcast #{@matching_msgs_count}"
            end
          end

          def data_description(data)
            if data.is_a?(RSpec::Matchers::Composable)
              data.description
            else
              data
            end
          end

          def pubsub_adapter
            ::ActionCable.server.pubsub
          end

          def check_channel_presence
            return if @channel.present? && @channel.respond_to?(:channel_name)

            error_msg = "Broadcasting channel can't be infered. Please, specify it with `from_channel`"
            raise ArgumentError, error_msg
          end
        end
        # rubocop: enable Metrics/ClassLength
      end
    end
  end
end