Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion guides/subscriptions/action_cable_implementation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
1 change: 1 addition & 0 deletions lib/graphql/subscriptions/action_cable_subscriptions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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:"
Expand Down
1 change: 1 addition & 0 deletions lib/graphql/testing.rb
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
# frozen_string_literal: true
require "graphql/testing/helpers"
require "graphql/testing/mock_action_cable"
111 changes: 111 additions & 0 deletions lib/graphql/testing/mock_action_cable.rb
Original file line number Diff line number Diff line change
@@ -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<Hash>] 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<String>] Streams that currently have subscribers
def mock_stream_names
@mock_streams.keys
end
end
end
end
end
92 changes: 15 additions & 77 deletions spec/graphql/subscriptions/action_cable_subscriptions_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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

Expand All @@ -115,20 +53,20 @@ 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)
{ result: { "data" => data }, more: true }
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({
Expand All @@ -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"})
Expand All @@ -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({
Expand All @@ -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({
Expand All @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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`:
Expand Down
Loading