# frozen_string_literal: true require "active_support" require "active_support/test_case" require "active_support/core_ext/hash/indifferent_access" require "json" module ActionCable module Channel class NonInferrableChannelError < ::StandardError def initialize(name) super "Unable to determine the channel to test from #{name}. " + "You'll need to specify it using `tests YourChannel` in your " + "test case definition." end end # Stub `stream_from` to track streams for the channel. # Add public aliases for `subscription_confirmation_sent?` and # `subscription_rejected?`. module ChannelStub def confirmed? subscription_confirmation_sent? end def rejected? subscription_rejected? end def stream_from(broadcasting, *) streams << broadcasting end def stop_all_streams @_streams = [] end def streams @_streams ||= [] end # Make periodic timers no-op def start_periodic_timers; end alias stop_periodic_timers start_periodic_timers end class ConnectionStub attr_reader :transmissions, :identifiers, :subscriptions, :logger def initialize(identifiers = {}) @transmissions = [] identifiers.each do |identifier, val| define_singleton_method(identifier) { val } end @subscriptions = ActionCable::Connection::Subscriptions.new(self) @identifiers = identifiers.keys @logger = ActiveSupport::TaggedLogging.new ActiveSupport::Logger.new(StringIO.new) end def transmit(cable_message) transmissions << cable_message.with_indifferent_access end end # Superclass for Action Cable channel functional tests. # # == Basic example # # Functional tests are written as follows: # 1. First, one uses the +subscribe+ method to simulate subscription creation. # 2. Then, one asserts whether the current state is as expected. "State" can be anything: # transmitted messages, subscribed streams, etc. # # For example: # # class ChatChannelTest < ActionCable::Channel::TestCase # def test_subscribed_with_room_number # # Simulate a subscription creation # subscribe room_number: 1 # # # Asserts that the subscription was successfully created # assert subscription.confirmed? # # # Asserts that the channel subscribes connection to a stream # assert_has_stream "chat_1" # # # Asserts that the channel subscribes connection to a specific # # stream created for a model # assert_has_stream_for Room.find(1) # end # # def test_does_not_stream_with_incorrect_room_number # subscribe room_number: -1 # # # Asserts that not streams was started # assert_no_streams # end # # def test_does_not_subscribe_without_room_number # subscribe # # # Asserts that the subscription was rejected # assert subscription.rejected? # end # end # # You can also perform actions: # def test_perform_speak # subscribe room_number: 1 # # perform :speak, message: "Hello, Rails!" # # assert_equal "Hello, Rails!", transmissions.last["text"] # end # # == Special methods # # ActionCable::Channel::TestCase will also automatically provide the following instance # methods for use in the tests: # # connection:: # An ActionCable::Channel::ConnectionStub, representing the current HTTP connection. # subscription:: # An instance of the current channel, created when you call `subscribe`. # transmissions:: # A list of all messages that have been transmitted into the channel. # # # == Channel is automatically inferred # # ActionCable::Channel::TestCase will automatically infer the channel under test # from the test class name. If the channel cannot be inferred from the test # class name, you can explicitly set it with +tests+. # # class SpecialEdgeCaseChannelTest < ActionCable::Channel::TestCase # tests SpecialChannel # end # # == Specifying connection identifiers # # You need to set up your connection manually to provide values for the identifiers. # To do this just use: # # stub_connection(user: users[:john]) # # == Testing broadcasting # # ActionCable::Channel::TestCase enhances ActionCable::TestHelper assertions (e.g. # +assert_broadcasts+) to handle broadcasting to models: # # # # in your channel # def speak(data) # broadcast_to room, text: data["message"] # end # # def test_speak # subscribe room_id: rooms[:chat].id # # assert_broadcasts_on(rooms[:chat], text: "Hello, Rails!") do # perform :speak, message: "Hello, Rails!" # end # end class TestCase < ActiveSupport::TestCase module Behavior extend ActiveSupport::Concern include ActiveSupport::Testing::ConstantLookup include ActionCable::TestHelper CHANNEL_IDENTIFIER = "test_stub" included do class_attribute :_channel_class attr_reader :connection, :subscription ActiveSupport.run_load_hooks(:action_cable_channel_test_case, self) end module ClassMethods def tests(channel) case channel when String, Symbol self._channel_class = channel.to_s.camelize.constantize when Module self._channel_class = channel else raise NonInferrableChannelError.new(channel) end end def channel_class if channel = self._channel_class channel else tests determine_default_channel(name) end end def determine_default_channel(name) channel = determine_constant_from_test_name(name) do |constant| Class === constant && constant < ActionCable::Channel::Base end raise NonInferrableChannelError.new(name) if channel.nil? channel end end # Setup test connection with the specified identifiers: # # class ApplicationCable < ActionCable::Connection::Base # identified_by :user, :token # end # # stub_connection(user: users[:john], token: 'my-secret-token') def stub_connection(identifiers = {}) @connection = ConnectionStub.new(identifiers) end # Subscribe to the channel under test. Optionally pass subscription parameters as a Hash. def subscribe(params = {}) @connection ||= stub_connection @subscription = self.class.channel_class.new(connection, CHANNEL_IDENTIFIER, params.with_indifferent_access) @subscription.singleton_class.include(ChannelStub) @subscription.subscribe_to_channel @subscription end # Unsubscribe the subscription under test. def unsubscribe check_subscribed! subscription.unsubscribe_from_channel end # Perform action on a channel. # # NOTE: Must be subscribed. def perform(action, data = {}) check_subscribed! subscription.perform_action(data.stringify_keys.merge("action" => action.to_s)) end # Returns messages transmitted into channel def transmissions # Return only directly sent message (via #transmit) connection.transmissions.map { |data| data["message"] }.compact end # Enhance TestHelper assertions to handle non-String # broadcastings def assert_broadcasts(stream_or_object, *args) super(broadcasting_for(stream_or_object), *args) end def assert_broadcast_on(stream_or_object, *args) super(broadcasting_for(stream_or_object), *args) end # Asserts that no streams have been started. # # def test_assert_no_started_stream # subscribe # assert_no_streams # end # def assert_no_streams assert subscription.streams.empty?, "No streams started was expected, but #{subscription.streams.count} found" end # Asserts that the specified stream has been started. # # def test_assert_started_stream # subscribe # assert_has_stream 'messages' # end # def assert_has_stream(stream) assert subscription.streams.include?(stream), "Stream #{stream} has not been started" end # Asserts that the specified stream for a model has started. # # def test_assert_started_stream_for # subscribe id: 42 # assert_has_stream_for User.find(42) # end # def assert_has_stream_for(object) assert_has_stream(broadcasting_for(object)) end private def check_subscribed! raise "Must be subscribed!" if subscription.nil? || subscription.rejected? end def broadcasting_for(stream_or_object) return stream_or_object if stream_or_object.is_a?(String) self.class.channel_class.broadcasting_for(stream_or_object) end end include Behavior end end end