aboutsummaryrefslogtreecommitdiffstats
path: root/actioncable/lib/action_cable/channel/test_case.rb
blob: c4cf0ac0e7f29bcee0e92c066d391ebf68579ab8 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
# 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:
    #
    # <b>connection</b>::
    #      An ActionCable::Channel::ConnectionStub, representing the current HTTP connection.
    # <b>subscription</b>::
    #      An instance of the current channel, created when you call `subscribe`.
    # <b>transmissions</b>::
    #      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(
              [self.class.channel_class.channel_name, stream_or_object]
            )
          end
      end

      include Behavior
    end
  end
end