diff --git a/guides/subscriptions/action_cable_implementation.md b/guides/subscriptions/action_cable_implementation.md index 3464159244..6969c45c4b 100644 --- a/guides/subscriptions/action_cable_implementation.md +++ b/guides/subscriptions/action_cable_implementation.md @@ -10,7 +10,7 @@ index: 4 [ActionCable](https://guides.rubyonrails.org/action_cable_overview.html) is a great platform for delivering GraphQL subscriptions on Rails 5+. It handles message passing (via `broadcast`) and transport (via `transmit` over a websocket). -To get started, see examples in the API docs: {{ "GraphQL::Subscriptions::ActionCableSubscriptions" | api_doc }}. +To get started, see examples in the API docs: {{ "GraphQL::Subscriptions::ActionCableSubscriptions" | api_doc }}. GraphQL-Ruby also includes a mock ActionCable implementation for testing: {{ "GraphQL::Testing::MockActionCable" | api_doc }}. See client usage for: diff --git a/lib/graphql/subscriptions/action_cable_subscriptions.rb b/lib/graphql/subscriptions/action_cable_subscriptions.rb index 5c01517877..bc3dc903ee 100644 --- a/lib/graphql/subscriptions/action_cable_subscriptions.rb +++ b/lib/graphql/subscriptions/action_cable_subscriptions.rb @@ -81,6 +81,7 @@ class Subscriptions # end # end # + # @see GraphQL::Testing::MockActionCable for test helpers class ActionCableSubscriptions < GraphQL::Subscriptions SUBSCRIPTION_PREFIX = "graphql-subscription:" EVENT_PREFIX = "graphql-event:" diff --git a/lib/graphql/testing.rb b/lib/graphql/testing.rb index fe48fe2605..7b3080b7e1 100644 --- a/lib/graphql/testing.rb +++ b/lib/graphql/testing.rb @@ -1,2 +1,3 @@ # frozen_string_literal: true require "graphql/testing/helpers" +require "graphql/testing/mock_action_cable" diff --git a/lib/graphql/testing/mock_action_cable.rb b/lib/graphql/testing/mock_action_cable.rb new file mode 100644 index 0000000000..86fd52aa3b --- /dev/null +++ b/lib/graphql/testing/mock_action_cable.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true +module GraphQL + module Testing + # A stub implementation of ActionCable. + # Any methods to support the mock backend have `mock` in the name. + # + # @example Configuring your schema to use MockActionCable in the test environment + # class MySchema < GraphQL::Schema + # # Use MockActionCable in test: + # use GraphQL::Subscriptions::ActionCableSubscriptions, + # action_cable: Rails.env.test? ? GraphQL::Testing::MockActionCable : ActionCable + # end + # + # @example Clearing old data before each test + # setup do + # GraphQL::Testing::MockActionCable.clear_mocks + # end + # + # @example Using MockActionCable in a test case + # # Create a channel to use in the test, pass it to GraphQL + # mock_channel = GraphQL::Testing::MockActionCable.get_mock_channel + # ActionCableTestSchema.execute("subscription { newsFlash { text } }", context: { channel: mock_channel }) + # + # # Trigger a subscription update + # ActionCableTestSchema.subscriptions.trigger(:news_flash, {}, {text: "After yesterday's rain, someone stopped on Rio Road to help a box turtle across five lanes of traffic"}) + # + # # Check messages on the channel + # expected_msg = { + # result: { + # "data" => { + # "newsFlash" => { + # "text" => "After yesterday's rain, someone stopped on Rio Road to help a box turtle across five lanes of traffic" + # } + # } + # }, + # more: true, + # } + # assert_equal [expected_msg], mock_channel.mock_broadcasted_messages + # + class MockActionCable + class MockChannel + def initialize + @mock_broadcasted_messages = [] + end + + # @return [Array] Payloads "sent" to this channel by GraphQL-Ruby + attr_reader :mock_broadcasted_messages + + # Called by ActionCableSubscriptions. Implements a Rails API. + def stream_from(stream_name, coder: nil, &block) + # Rails uses `coder`, we don't + block ||= ->(msg) { @mock_broadcasted_messages << msg } + MockActionCable.mock_stream_for(stream_name).add_mock_channel(self, block) + end + end + + # Used by mock code + # @api private + class MockStream + def initialize + @mock_channels = {} + end + + def add_mock_channel(channel, handler) + @mock_channels[channel] = handler + end + + def mock_broadcast(message) + @mock_channels.each do |channel, handler| + handler && handler.call(message) + end + end + end + + class << self + # Call this before each test run to make sure that MockActionCable's data is empty + def clear_mocks + @mock_streams = {} + end + + # Implements Rails API + def server + self + end + + # Implements Rails API + def broadcast(stream_name, message) + stream = @mock_streams[stream_name] + stream && stream.mock_broadcast(message) + end + + # Used by mock code + def mock_stream_for(stream_name) + @mock_streams[stream_name] ||= MockStream.new + end + + # Use this as `context[:channel]` to simulate an ActionCable channel + # + # @return [GraphQL::Testing::MockActionCable::MockChannel] + def get_mock_channel + MockChannel.new + end + + # @return [Array] Streams that currently have subscribers + def mock_stream_names + @mock_streams.keys + end + end + end + end +end diff --git a/spec/graphql/subscriptions/action_cable_subscriptions_spec.rb b/spec/graphql/subscriptions/action_cable_subscriptions_spec.rb index 996b084b22..a281e7d4f4 100644 --- a/spec/graphql/subscriptions/action_cable_subscriptions_spec.rb +++ b/spec/graphql/subscriptions/action_cable_subscriptions_spec.rb @@ -1,69 +1,7 @@ # frozen_string_literal: true require "spec_helper" - -describe GraphQL::Subscriptions::ActionCableSubscriptions do - # A stub implementation of ActionCable. - # Any methods to support the mock backend have `mock` in the name. - class MockActionCable - class MockChannel - def initialize - @mock_broadcasted_messages = [] - end - - attr_reader :mock_broadcasted_messages - - def stream_from(stream_name, coder: nil, &block) - # Rails uses `coder`, we don't - block ||= ->(msg) { @mock_broadcasted_messages << msg } - MockActionCable.mock_stream_for(stream_name).add_mock_channel(self, block) - end - end - - class MockStream - def initialize - @mock_channels = {} - end - - def add_mock_channel(channel, handler) - @mock_channels[channel] = handler - end - - def mock_broadcast(message) - @mock_channels.each do |channel, handler| - handler && handler.call(message) - end - end - end - - class << self - def clear_mocks - @mock_streams = {} - end - - def server - self - end - - def broadcast(stream_name, message) - stream = @mock_streams[stream_name] - stream && stream.mock_broadcast(message) - end - - def mock_stream_for(stream_name) - @mock_streams[stream_name] ||= MockStream.new - end - - def get_mock_channel - MockChannel.new - end - - def mock_stream_names - @mock_streams.keys - end - end - end - +describe "GraphQL::Subscriptions::ActionCableSubscriptions" do class ActionCableTestSchema < GraphQL::Schema class Query < GraphQL::Schema::Object field :int, Integer @@ -106,7 +44,7 @@ class Subscription < GraphQL::Schema::Object query(Query) subscription(Subscription) use GraphQL::Subscriptions::ActionCableSubscriptions, - action_cable: MockActionCable, + action_cable: GraphQL::Testing::MockActionCable, action_cable_coder: JSON end @@ -115,12 +53,12 @@ class NamespacedActionCableTestSchema < GraphQL::Schema subscription(ActionCableTestSchema::Subscription) use GraphQL::Subscriptions::ActionCableSubscriptions, namespace: "other:", - action_cable: MockActionCable, + action_cable: GraphQL::Testing::MockActionCable, action_cable_coder: JSON end before do - MockActionCable.clear_mocks + GraphQL::Testing::MockActionCable.clear_mocks end def subscription_update(data) @@ -128,7 +66,7 @@ def subscription_update(data) end it "sends updates over the given `action_cable:`" do - mock_channel = MockActionCable.get_mock_channel + mock_channel = GraphQL::Testing::MockActionCable.get_mock_channel ActionCableTestSchema.execute("subscription { newsFlash { text } }", context: { channel: mock_channel }) ActionCableTestSchema.subscriptions.trigger(:news_flash, {}, {text: "After yesterday's rain, someone stopped on Rio Road to help a box turtle across five lanes of traffic"}) expected_msg = subscription_update({ @@ -140,7 +78,7 @@ def subscription_update(data) end it "uses arguments to divide traffic" do - mock_channel = MockActionCable.get_mock_channel + mock_channel = GraphQL::Testing::MockActionCable.get_mock_channel ActionCableTestSchema.execute("subscription { newsFlash(maxPerHour: 3) { text } }", context: { channel: mock_channel }) ActionCableTestSchema.subscriptions.trigger(:news_flash, {}, {text: "Sunrise enjoyed over a cup of coffee"}) ActionCableTestSchema.subscriptions.trigger(:news_flash, {max_per_hour: 3}, {text: "Neighbor shares bumper crop of summer squash with widow next door"}) @@ -154,7 +92,7 @@ def subscription_update(data) end it "handles custom argument correctly" do - mock_channel = MockActionCable.get_mock_channel + mock_channel = GraphQL::Testing::MockActionCable.get_mock_channel ActionCableTestSchema.execute("subscription { newsFlash(filter: { trending: true }) { text } }", context: { channel: mock_channel }) ActionCableTestSchema.subscriptions.trigger(:news_flash, {filter: {trending: true}}, {text: "Neighbor shares bumper crop of summer squash with widow next door"}) expected_msg = subscription_update({ @@ -166,7 +104,7 @@ def subscription_update(data) end it "handles nested custom argument correctly" do - mock_channel = MockActionCable.get_mock_channel + mock_channel = GraphQL::Testing::MockActionCable.get_mock_channel ActionCableTestSchema.execute("subscription { newsFlash(keywords: [{ value: \"rain\", fuzzy: true }]) { text } }", context: { channel: mock_channel }) ActionCableTestSchema.subscriptions.trigger(:news_flash, {keywords: [{value: "rain", fuzzy: true}]}, {text: "After yesterday's rain, someone stopped on Rio Road to help a box turtle across five lanes of traffic"}) expected_msg = subscription_update({ @@ -178,11 +116,11 @@ def subscription_update(data) end it "uses namespace to divide traffic" do - mock_channel_1 = MockActionCable.get_mock_channel + mock_channel_1 = GraphQL::Testing::MockActionCable.get_mock_channel ctx_1 = { channel: mock_channel_1 } ActionCableTestSchema.execute("subscription { newsFlash { text } }", context: ctx_1) - mock_channel_2 = MockActionCable.get_mock_channel + mock_channel_2 = GraphQL::Testing::MockActionCable.get_mock_channel ctx_2 = { channel: mock_channel_2 } NamespacedActionCableTestSchema.execute("subscription { newsFlash { text } }", context: ctx_2) @@ -212,11 +150,11 @@ def subscription_update(data) "graphql-subscription:other:#{ctx_2[:subscription_id]}", "graphql-event:other::newsFlash:", ] - assert_equal expected_streams, MockActionCable.mock_stream_names + assert_equal expected_streams, GraphQL::Testing::MockActionCable.mock_stream_names end it "supports no_update" do - mock_channel = MockActionCable.get_mock_channel + mock_channel = GraphQL::Testing::MockActionCable.get_mock_channel ctx = { channel: mock_channel } ActionCableTestSchema.execute("subscription { evenCounter { count } }", context: ctx) @@ -330,17 +268,17 @@ def self.dump(obj) end use GraphQL::Subscriptions::ActionCableSubscriptions, - action_cable: MockActionCable, + action_cable: GraphQL::Testing::MockActionCable, action_cable_coder: JSON, serializer: Serialize end it "works with multi-tenant architecture" do - mock_channel_1 = MockActionCable.get_mock_channel + mock_channel_1 = GraphQL::Testing::MockActionCable.get_mock_channel ctx_1 = { channel: mock_channel_1, tenant: "tenant-1" } MultiTenantSchema.execute("subscription { pointScored { score player { name } } }", context: ctx_1) - mock_channel_2 = MockActionCable.get_mock_channel + mock_channel_2 = GraphQL::Testing::MockActionCable.get_mock_channel ctx_2 = { channel: mock_channel_2, tenant: "tenant-2" } MultiTenantSchema.execute("subscription { pointScored { score player { name } } }", context: ctx_2) # This will use the `.find` in `def update`: