graphql 1.9.0.pre2 → 1.9.0.pre3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ class Schema
5
+ # This class can be extended to create fields on your subscription root.
6
+ #
7
+ # It provides hooks for the different parts of the subscription lifecycle:
8
+ #
9
+ # - `#authorized?`: called before initial subscription and subsequent updates
10
+ # - `#subscribe`: called for the initial subscription
11
+ # - `#update`: called for subsequent update
12
+ #
13
+ # Also, `#unsubscribe` terminates the subscription.
14
+ class Subscription < GraphQL::Schema::Resolver
15
+ class EarlyTerminationError < StandardError
16
+ end
17
+
18
+ # Raised when `unsubscribe` is called; caught by `subscriptions.rb`
19
+ class UnsubscribedError < EarlyTerminationError
20
+ end
21
+
22
+ # Raised when `no_update` is returned; caught by `subscriptions.rb`
23
+ class NoUpdateError < EarlyTerminationError
24
+ end
25
+ extend GraphQL::Schema::Resolver::HasPayloadType
26
+ extend GraphQL::Schema::Member::HasFields
27
+
28
+ # The generated payload type is required; If there's no payload,
29
+ # propagate null.
30
+ null false
31
+
32
+ def initialize(object:, context:)
33
+ super
34
+ # Figure out whether this is an update or an initial subscription
35
+ @mode = context.query.subscription_update? ? :update : :subscribe
36
+ end
37
+
38
+ # Implement the {Resolve} API
39
+ def resolve(**args)
40
+ # Dispatch based on `@mode`, which will raise a `NoMethodError` if we ever
41
+ # have an unexpected `@mode`
42
+ public_send("resolve_#{@mode}", args)
43
+ end
44
+
45
+ # Wrap the user-defined `#subscribe` hook
46
+ def resolve_subscribe(args)
47
+ ret_val = args.any? ? subscribe(args) : subscribe
48
+ if ret_val == :no_response
49
+ context.skip
50
+ else
51
+ ret_val
52
+ end
53
+ end
54
+
55
+ # Default implementation returns the root object.
56
+ # Override it to return an object or
57
+ # `:no_response` to return nothing.
58
+ #
59
+ # The default is `:no_response`.
60
+ def subscribe(args)
61
+ :no_response
62
+ end
63
+
64
+ # Wrap the user-provided `#update` hook
65
+ def resolve_update(args)
66
+ ret_val = args.any? ? update(args) : update
67
+ if ret_val == :no_update
68
+ raise NoUpdateError
69
+ else
70
+ ret_val
71
+ end
72
+ end
73
+
74
+ # The default implementation returns the root object.
75
+ # Override it to return `:no_update` if you want to
76
+ # skip updates sometimes. Or override it to return a different object.
77
+ def update(args = {})
78
+ object
79
+ end
80
+
81
+ # If an argument is flagged with `loads:` and no object is found for it,
82
+ # remove this subscription (assuming that the object was deleted in the meantime,
83
+ # or that it became inaccessible).
84
+ def load_application_object_failed(err)
85
+ if @mode == :update
86
+ unsubscribe
87
+ end
88
+ super
89
+ end
90
+
91
+ # Call this to halt execution and remove this subscription from the system
92
+ def unsubscribe
93
+ raise UnsubscribedError
94
+ end
95
+ end
96
+ end
97
+ end
@@ -100,6 +100,12 @@ module GraphQL
100
100
  }
101
101
  )
102
102
  deliver(subscription_id, result)
103
+ rescue GraphQL::Schema::Subscription::NoUpdateError
104
+ # This update was skipped in user code; do nothing.
105
+ rescue GraphQL::Schema::Subscription::UnsubscribedError
106
+ # `unsubscribe` was called, clean up on our side
107
+ # TODO also send `{more: false}` to client?
108
+ delete_subscription(subscription_id)
103
109
  end
104
110
 
105
111
  # Event `event` occurred on `object`,
@@ -133,9 +139,8 @@ module GraphQL
133
139
  # The result should be send to `subscription_id`.
134
140
  # @param subscription_id [String]
135
141
  # @param result [Hash]
136
- # @param context [GraphQL::Query::Context]
137
142
  # @return [void]
138
- def deliver(subscription_id, result, context)
143
+ def deliver(subscription_id, result)
139
144
  raise NotImplementedError
140
145
  end
141
146
 
@@ -148,6 +153,14 @@ module GraphQL
148
153
  raise NotImplementedError
149
154
  end
150
155
 
156
+ # A subscription was terminated server-side.
157
+ # Clean up the database.
158
+ # @param subscription_id [String]
159
+ # @return void.
160
+ def delete_subscription(subscription_id)
161
+ raise NotImplementedError
162
+ end
163
+
151
164
  # @return [String] A new unique identifier for a subscription
152
165
  def build_id
153
166
  SecureRandom.uuid
@@ -4,8 +4,30 @@ module GraphQL
4
4
  class Subscriptions
5
5
  # Extend this module in your subscription root when using {GraphQL::Execution::Interpreter}.
6
6
  module SubscriptionRoot
7
+ def self.extended(child_cls)
8
+ child_cls.include(InstanceMethods)
9
+ end
10
+
11
+ # This is for maintaining backwards compatibility:
12
+ # if a subscription field is created without a `subscription:` resolver class,
13
+ # then implement the method with the previous default behavior.
14
+ module InstanceMethods
15
+ def skip_subscription_root(*)
16
+ if context.query.subscription_update?
17
+ object
18
+ else
19
+ context.skip
20
+ end
21
+ end
22
+ end
23
+
7
24
  def field(*args, extensions: [], **rest, &block)
8
25
  extensions += [Extension]
26
+ # Backwards-compat for schemas
27
+ if !rest[:subscription]
28
+ name = args.first
29
+ alias_method(name, :skip_subscription_root)
30
+ end
9
31
  super(*args, extensions: extensions, **rest, &block)
10
32
  end
11
33
 
@@ -22,16 +44,17 @@ module GraphQL
22
44
  context: context,
23
45
  field: field,
24
46
  )
25
- context.skip
47
+ # TODO compat with non-class-based subscriptions?
48
+ value
26
49
  elsif context.query.subscription_topic == Subscriptions::Event.serialize(
27
50
  field.name,
28
51
  arguments,
29
52
  field,
30
53
  scope: (field.subscription_scope ? context[field.subscription_scope] : nil),
31
54
  )
32
- # The root object is _already_ the subscription update,
33
- # it was passed to `.trigger`
34
- object.object
55
+ # This is a subscription update. The resolver returned `skip` if it should be skipped,
56
+ # or else it returned an object to resolve the update.
57
+ value
35
58
  else
36
59
  # This is a subscription update, but this event wasn't triggered.
37
60
  context.skip
@@ -1,4 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
  module GraphQL
3
- VERSION = "1.9.0.pre2"
3
+ VERSION = "1.9.0.pre3"
4
4
  end
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: /Users/rmosolgo/code/graphql-ruby
3
3
  specs:
4
- graphql (1.8.8)
4
+ graphql (1.9.0.pre2)
5
5
 
6
6
  GEM
7
7
  remote: https://rubygems.org/
@@ -154,4 +154,4 @@ DEPENDENCIES
154
154
  selenium-webdriver
155
155
 
156
156
  BUNDLED WITH
157
- 1.16.1
157
+ 2.0.1
@@ -14,15 +14,15 @@ class GraphqlChannel < ActionCable::Channel::Base
14
14
  class SubscriptionType < GraphQL::Schema::Object
15
15
  if TESTING_INTERPRETER
16
16
  extend GraphQL::Subscriptions::SubscriptionRoot
17
+ else
18
+ def payload(id:)
19
+ id
20
+ end
17
21
  end
18
22
 
19
23
  field :payload, PayloadType, null: false do
20
24
  argument :id, ID, required: true
21
25
  end
22
-
23
- def payload(id:)
24
- id
25
- end
26
26
  end
27
27
 
28
28
  # Wacky behavior around the number 4
@@ -194,3 +194,6 @@ GraphqlChannel transmitting {"result"=>{"data"=>{"payload"=>{"value"=>400}}}, "m
194
194
  Finished "/cable/" [WebSocket] for 127.0.0.1 at 2018-10-05 11:46:25 -0400
195
195
  GraphqlChannel stopped streaming from graphql-subscription:c781954b-7b9a-4742-9f9c-69b869d85fad
196
196
  GraphqlChannel stopped streaming from graphql-event::payload:id:updates-2
197
+ -----------------------------------------------------------
198
+ ActionCableSubscriptionsTest: test_it_handles_subscriptions
199
+ -----------------------------------------------------------
@@ -14,12 +14,19 @@ describe GraphQL::Execution::Lookahead do
14
14
  DATA.find { |b| b.name == name }
15
15
  end
16
16
 
17
+ module Node
18
+ include GraphQL::Schema::Interface
19
+ field :id, ID, null: false
20
+ end
21
+
17
22
  class BirdGenus < GraphQL::Schema::Object
18
23
  field :latin_name, String, null: false
24
+ field :id, ID, null: false, method: :latin_name
19
25
  end
20
26
 
21
27
  class BirdSpecies < GraphQL::Schema::Object
22
28
  field :name, String, null: false
29
+ field :id, ID, null: false, method: :name
23
30
  field :is_waterfowl, Boolean, null: false
24
31
  field :similar_species, [BirdSpecies], null: false
25
32
 
@@ -46,6 +53,18 @@ describe GraphQL::Execution::Lookahead do
46
53
  def find_bird_species(by_name:)
47
54
  DATA.find_by_name(by_name)
48
55
  end
56
+
57
+ field :node, Node, null: true do
58
+ argument :id, ID, required: true
59
+ end
60
+
61
+ def node(id:)
62
+ if (node = DATA.find_by_name(id))
63
+ node
64
+ else
65
+ DATA.map { |d| d.genus }.select { |g| g.name == id }
66
+ end
67
+ end
49
68
  end
50
69
 
51
70
  class LookaheadInstrumenter
@@ -101,6 +120,40 @@ describe GraphQL::Execution::Lookahead do
101
120
  assert_equal true, query.lookahead.selects?("__typename")
102
121
  end
103
122
 
123
+ describe "fields on interfaces" do
124
+ let(:document) {
125
+ GraphQL.parse <<-GRAPHQL
126
+ query {
127
+ node(id: "Cardinal") {
128
+ id
129
+ ... on BirdSpecies {
130
+ name
131
+ }
132
+ ...Other
133
+ }
134
+ }
135
+ fragment Other on BirdGenus {
136
+ latinName
137
+ }
138
+ GRAPHQL
139
+ }
140
+
141
+ it "finds fields on object types and interface types" do
142
+ node_lookahead = query.lookahead.selection("node")
143
+ assert_equal [:id, :name, :latin_name], node_lookahead.selections.map(&:name)
144
+ end
145
+ end
146
+
147
+ describe "inspect" do
148
+ it "works for root lookaheads" do
149
+ assert_includes query.lookahead.inspect, "#<GraphQL::Execution::Lookahead @root_type="
150
+ end
151
+
152
+ it "works for field lookaheads" do
153
+ assert_includes query.lookahead.selection(:find_bird_species).inspect, "#<GraphQL::Execution::Lookahead @field="
154
+ end
155
+ end
156
+
104
157
  describe "constraints by arguments" do
105
158
  let(:lookahead) do
106
159
  query.lookahead
@@ -245,7 +298,7 @@ describe GraphQL::Execution::Lookahead do
245
298
  ast_node = document.definitions.first.selections.first
246
299
  field = LookaheadTest::Query.fields["findBirdSpecies"]
247
300
  lookahead = GraphQL::Execution::Lookahead.new(query: query, ast_nodes: [ast_node], field: field)
248
- assert_equal lookahead.selections.map(&:name), [:name, :similar_species]
301
+ assert_equal [:name, :similar_species], lookahead.selections.map(&:name)
249
302
  end
250
303
 
251
304
  it "filters outs selections which do not match arguments" do
@@ -257,10 +257,10 @@ describe GraphQL::Query::Executor do
257
257
  {
258
258
  "message" => "Variable input of type ReplaceValuesInput! was provided invalid value",
259
259
  "locations" => [{ "line" => 1, "column" => 13 }],
260
- "value" => nil,
261
- "problems" => [
262
- { "path" => [], "explanation" => "Expected value to not be null" }
263
- ]
260
+ "extensions" => {
261
+ "value" => nil,
262
+ "problems" => [{ "path" => [], "explanation" => "Expected value to not be null" }]
263
+ }
264
264
  }
265
265
  ]
266
266
  }
@@ -277,10 +277,10 @@ describe GraphQL::Query::Executor do
277
277
  {
278
278
  "message" => "Variable input of type ReplaceValuesInput! was provided invalid value",
279
279
  "locations" => [{ "line" => 1, "column" => 13 }],
280
- "value" => {},
281
- "problems" => [
282
- { "path" => ["values"], "explanation" => "Expected value to not be null" }
283
- ]
280
+ "extensions" => {
281
+ "value" => {},
282
+ "problems" => [{ "path" => ["values"], "explanation" => "Expected value to not be null" }]
283
+ }
284
284
  }
285
285
  ]
286
286
  }
@@ -297,11 +297,13 @@ describe GraphQL::Query::Executor do
297
297
  {
298
298
  "message" => "Variable input of type [DairyProductInput] was provided invalid value",
299
299
  "locations" => [{ "line" => 1, "column" => 10 }],
300
- "value" => [{ "foo" => "bar" }],
301
- "problems" => [
302
- { "path" => [0, "foo"], "explanation" => "Field is not defined on DairyProductInput" },
303
- { "path" => [0, "source"], "explanation" => "Expected value to not be null" }
304
- ]
300
+ "extensions" => {
301
+ "value" => [{ "foo" => "bar" }],
302
+ "problems" => [
303
+ { "path" => [0, "foo"], "explanation" => "Field is not defined on DairyProductInput" },
304
+ { "path" => [0, "source"], "explanation" => "Expected value to not be null" }
305
+ ]
306
+ }
305
307
  }
306
308
  ]
307
309
  }
@@ -435,8 +435,10 @@ describe GraphQL::Query do
435
435
  {
436
436
  "message" => "Variable cheeseId of type Int! was provided invalid value",
437
437
  "locations"=>[{ "line" => 2, "column" => 23 }],
438
- "value" => "2",
439
- "problems" => [{ "path" => [], "explanation" => 'Could not coerce value "2" to Int' }]
438
+ "extensions" => {
439
+ "value" => "2",
440
+ "problems" => [{ "path" => [], "explanation" => 'Could not coerce value "2" to Int' }]
441
+ }
440
442
  }
441
443
  ]
442
444
  }
@@ -453,8 +455,10 @@ describe GraphQL::Query do
453
455
  {
454
456
  "message" => "Variable cheeseId of type Int! was provided invalid value",
455
457
  "locations" => [{"line" => 2, "column" => 23}],
456
- "value" => nil,
457
- "problems" => [{ "path" => [], "explanation" => "Expected value to not be null" }]
458
+ "extensions" => {
459
+ "value" => nil,
460
+ "problems" => [{ "path" => [], "explanation" => "Expected value to not be null" }]
461
+ }
458
462
  }
459
463
  ]
460
464
  }
@@ -471,8 +475,10 @@ describe GraphQL::Query do
471
475
  {
472
476
  "message" => "Variable cheeseId of type Int! was provided invalid value",
473
477
  "locations" => [{"line" => 2, "column" => 23}],
474
- "value" => nil,
475
- "problems" => [{ "path" => [], "explanation" => "Expected value to not be null" }]
478
+ "extensions" => {
479
+ "value" => nil,
480
+ "problems" => [{ "path" => [], "explanation" => "Expected value to not be null" }]
481
+ }
476
482
  }
477
483
  ]
478
484
  }
@@ -0,0 +1,416 @@
1
+ # frozen_string_literal: true
2
+ require "spec_helper"
3
+
4
+ describe GraphQL::Schema::Subscription do
5
+ class SubscriptionFieldSchema < GraphQL::Schema
6
+ TOOTS = []
7
+ ALL_USERS = {
8
+ "dhh" => {handle: "dhh", private: false},
9
+ "matz" => {handle: "matz", private: false},
10
+ "_why" => {handle: "_why", private: true},
11
+ }
12
+
13
+ USERS = {}
14
+
15
+ class User < GraphQL::Schema::Object
16
+ field :handle, String, null: false
17
+ field :private, Boolean, null: false
18
+ end
19
+
20
+ class Toot < GraphQL::Schema::Object
21
+ field :handle, String, null: false
22
+ field :body, String, null: false
23
+ end
24
+
25
+ class Query < GraphQL::Schema::Object
26
+ field :toots, [Toot], null: false
27
+
28
+ def toots
29
+ TOOTS
30
+ end
31
+ end
32
+
33
+ class BaseSubscription < GraphQL::Schema::Subscription
34
+ end
35
+
36
+ class TootWasTooted < BaseSubscription
37
+ argument :handle, String, required: true, loads: User, as: :user
38
+ field :toot, Toot, null: false
39
+ field :user, User, null: false
40
+ # Can't subscribe to private users
41
+ def authorized?(user:)
42
+ if user[:private]
43
+ raise GraphQL::ExecutionError, "Can't subscribe to private user"
44
+ else
45
+ true
46
+ end
47
+ end
48
+
49
+ def subscribe(user:)
50
+ if context[:prohibit_subscriptions]
51
+ raise GraphQL::ExecutionError, "You don't have permission to subscribe"
52
+ else
53
+ # Default is to return :no_response
54
+ super
55
+ end
56
+ end
57
+
58
+ def update(user:)
59
+ if context[:viewer] == user
60
+ # don't update for one's own toots.
61
+ # (IRL it would make more sense to implement this in `#subscribe`)
62
+ :no_update
63
+ else
64
+ # This assumes that trigger object can fulfill `{toot:, user:}`,
65
+ # for testing that the default implementation is `return object`
66
+ super
67
+ end
68
+ end
69
+ end
70
+
71
+ # Test initial response, which returns all users
72
+ class UsersJoined < BaseSubscription
73
+ class UsersJoinedManualPayload < GraphQL::Schema::Object
74
+ field :users, [User], null: true,
75
+ description: "Includes newly-created users, or all users on the initial load"
76
+ end
77
+
78
+ payload_type UsersJoinedManualPayload
79
+
80
+ def subscribe
81
+ { users: USERS.values }
82
+ end
83
+
84
+ # Test returning a custom object from #update
85
+ def update
86
+ { users: object[:new_users] }
87
+ end
88
+ end
89
+
90
+ class Subscription < GraphQL::Schema::Object
91
+ extend GraphQL::Subscriptions::SubscriptionRoot
92
+ field :toot_was_tooted, subscription: TootWasTooted
93
+ field :users_joined, subscription: UsersJoined
94
+ end
95
+
96
+ class Mutation < GraphQL::Schema::Object
97
+ field :toot, Toot, null: false do
98
+ argument :body, String, required: true
99
+ end
100
+
101
+ def toot(body:)
102
+ handle = context[:viewer][:handle]
103
+ toot = { handle: handle, body: body }
104
+ TOOTS << toot
105
+ SubscriptionFieldSchema.trigger(:toot_was_tooted, {handle: handle}, toot)
106
+ end
107
+ end
108
+
109
+ query(Query)
110
+ mutation(Mutation)
111
+ subscription(Subscription)
112
+ use GraphQL::Execution::Interpreter
113
+ use GraphQL::Analysis::AST
114
+
115
+ def self.object_from_id(id, ctx)
116
+ USERS[id]
117
+ end
118
+
119
+
120
+ class InMemorySubscriptions < GraphQL::Subscriptions
121
+ SUBSCRIPTION_REGISTRY = {}
122
+
123
+ EVENT_REGISTRY = Hash.new { |h, k| h[k] = [] }
124
+
125
+ def write_subscription(query, events)
126
+ query.context[:subscription_mailbox] = []
127
+ subscription_id = build_id
128
+ events.each do |ev|
129
+ EVENT_REGISTRY[ev.topic] << subscription_id
130
+ end
131
+ SUBSCRIPTION_REGISTRY[subscription_id] = [query, events]
132
+ end
133
+
134
+ def each_subscription_id(event)
135
+ EVENT_REGISTRY[event.topic].each do |sub_id|
136
+ yield(sub_id)
137
+ end
138
+ end
139
+
140
+ def read_subscription(subscription_id)
141
+ query, _events = SUBSCRIPTION_REGISTRY[subscription_id]
142
+ {
143
+ query_string: query.query_string,
144
+ context: query.context.to_h,
145
+ variables: query.provided_variables,
146
+ operation_name: query.selected_operation_name,
147
+ }
148
+ end
149
+
150
+ def deliver(subscription_id, result)
151
+ query, _events = SUBSCRIPTION_REGISTRY[subscription_id]
152
+ query.context[:subscription_mailbox] << result
153
+ end
154
+
155
+ def delete_subscription(subscription_id)
156
+ _query, events = SUBSCRIPTION_REGISTRY.delete(subscription_id)
157
+ events.each do |ev|
158
+ EVENT_REGISTRY[ev.topic].delete(subscription_id)
159
+ end
160
+ end
161
+ end
162
+
163
+ use InMemorySubscriptions
164
+ end
165
+
166
+ def exec_query(*args)
167
+ SubscriptionFieldSchema.execute(*args)
168
+ end
169
+
170
+ def in_memory_subscription_count
171
+ SubscriptionFieldSchema::InMemorySubscriptions::SUBSCRIPTION_REGISTRY.size
172
+ end
173
+
174
+ before do
175
+ # Reset databases
176
+ SubscriptionFieldSchema::TOOTS.clear
177
+ # Reset in order:
178
+ SubscriptionFieldSchema::USERS.clear
179
+ SubscriptionFieldSchema::ALL_USERS.map do |k, v|
180
+ SubscriptionFieldSchema::USERS[k] = v.dup
181
+ end
182
+
183
+ SubscriptionFieldSchema::InMemorySubscriptions::SUBSCRIPTION_REGISTRY.clear
184
+ SubscriptionFieldSchema::InMemorySubscriptions::EVENT_REGISTRY.clear
185
+ end
186
+
187
+ it "generates a return type" do
188
+ return_type = SubscriptionFieldSchema::TootWasTooted.payload_type
189
+ assert_equal "TootWasTootedPayload", return_type.graphql_name
190
+ assert_equal ["toot", "user"], return_type.fields.keys
191
+ end
192
+
193
+ it "can use a premade `payload_type`" do
194
+ return_type = SubscriptionFieldSchema::UsersJoined.payload_type
195
+ assert_equal "UsersJoinedManualPayload", return_type.graphql_name
196
+ assert_equal ["users"], return_type.fields.keys
197
+ assert_equal SubscriptionFieldSchema::UsersJoined::UsersJoinedManualPayload, return_type
198
+ end
199
+
200
+ describe "initial subscription" do
201
+ it "calls #subscribe for the initial subscription and returns the result" do
202
+ res = exec_query <<-GRAPHQL
203
+ subscription {
204
+ usersJoined {
205
+ users {
206
+ handle
207
+ }
208
+ }
209
+ }
210
+ GRAPHQL
211
+
212
+ assert_equal ["dhh", "matz", "_why"], res["data"]["usersJoined"]["users"].map { |u| u["handle"] }
213
+ assert_equal 1, in_memory_subscription_count
214
+ end
215
+
216
+ it "rejects the subscription if #subscribe raises an error" do
217
+ res = exec_query <<-GRAPHQL, context: { prohibit_subscriptions: true }
218
+ subscription {
219
+ tootWasTooted(handle: "matz") {
220
+ toot { body }
221
+ }
222
+ }
223
+ GRAPHQL
224
+
225
+ expected_response = {
226
+ "data"=>nil,
227
+ "errors"=>[
228
+ {
229
+ "message"=>"You don't have permission to subscribe",
230
+ "locations"=>[{"line"=>2, "column"=>9}],
231
+ "path"=>["tootWasTooted"]
232
+ }
233
+ ]
234
+ }
235
+
236
+ assert_equal(expected_response, res)
237
+ assert_equal 0, in_memory_subscription_count
238
+ end
239
+
240
+ it "doesn't subscribe if `loads:` fails" do
241
+ res = exec_query <<-GRAPHQL
242
+ subscription {
243
+ tootWasTooted(handle: "jack") {
244
+ toot { body }
245
+ }
246
+ }
247
+ GRAPHQL
248
+
249
+ expected_response = {
250
+ "data" => nil,
251
+ "errors" => [
252
+ {
253
+ "message"=>"No object found for `handle: \"jack\"`",
254
+ "locations"=>[{"line"=>2, "column"=>9}],
255
+ "path"=>["tootWasTooted"]
256
+ }
257
+ ]
258
+ }
259
+ assert_equal(expected_response, res)
260
+ assert_equal 0, in_memory_subscription_count
261
+ end
262
+
263
+ it "rejects if #authorized? fails" do
264
+ res = exec_query <<-GRAPHQL
265
+ subscription {
266
+ tootWasTooted(handle: "_why") {
267
+ toot { body }
268
+ }
269
+ }
270
+ GRAPHQL
271
+ expected_response = {
272
+ "data"=>nil,
273
+ "errors"=>[
274
+ {
275
+ "message"=>"Can't subscribe to private user",
276
+ "locations"=>[{"line"=>2, "column"=>9}],
277
+ "path"=>["tootWasTooted"]
278
+ },
279
+ ],
280
+ }
281
+ assert_equal(expected_response, res)
282
+ end
283
+
284
+ it "sends no initial response if :no_response is returned, which is the default" do
285
+ assert_equal 0, in_memory_subscription_count
286
+
287
+ res = exec_query <<-GRAPHQL
288
+ subscription {
289
+ tootWasTooted(handle: "matz") {
290
+ toot { body }
291
+ }
292
+ }
293
+ GRAPHQL
294
+ assert_equal({"data" => {}}, res)
295
+ assert_equal 1, in_memory_subscription_count
296
+ end
297
+ end
298
+
299
+ describe "updates" do
300
+ it "updates with `object` by default" do
301
+ res = exec_query <<-GRAPHQL
302
+ subscription {
303
+ tootWasTooted(handle: "matz") {
304
+ toot { body }
305
+ }
306
+ }
307
+ GRAPHQL
308
+ assert_equal 1, in_memory_subscription_count
309
+ obj = OpenStruct.new(toot: { body: "I am a C programmer" }, user: SubscriptionFieldSchema::USERS["matz"])
310
+ SubscriptionFieldSchema.subscriptions.trigger(:toot_was_tooted, {handle: "matz"}, obj)
311
+
312
+ mailbox = res.context[:subscription_mailbox]
313
+ update_payload = mailbox.first
314
+ assert_equal "I am a C programmer", update_payload["data"]["tootWasTooted"]["toot"]["body"]
315
+ end
316
+
317
+ it "updates with the returned value" do
318
+ res = exec_query <<-GRAPHQL
319
+ subscription {
320
+ usersJoined {
321
+ users {
322
+ handle
323
+ }
324
+ }
325
+ }
326
+ GRAPHQL
327
+
328
+ assert_equal 1, in_memory_subscription_count
329
+ SubscriptionFieldSchema.subscriptions.trigger(:users_joined, {}, {new_users: [{handle: "eileencodes"}, {handle: "tenderlove"}]})
330
+
331
+ update = res.context[:subscription_mailbox].first
332
+ assert_equal [{"handle" => "eileencodes"}, {"handle" => "tenderlove"}], update["data"]["usersJoined"]["users"]
333
+ end
334
+
335
+ it "skips the update if `:no_update` is returned, but updates other subscribers" do
336
+ query_str = <<-GRAPHQL
337
+ subscription {
338
+ tootWasTooted(handle: "matz") {
339
+ toot { body }
340
+ }
341
+ }
342
+ GRAPHQL
343
+
344
+ res1 = exec_query(query_str)
345
+ res2 = exec_query(query_str, context: { viewer: SubscriptionFieldSchema::USERS["matz"] })
346
+ assert_equal 2, in_memory_subscription_count
347
+
348
+ obj = OpenStruct.new(toot: { body: "Merry Christmas, here's a new Ruby version" }, user: SubscriptionFieldSchema::USERS["matz"])
349
+ SubscriptionFieldSchema.subscriptions.trigger(:toot_was_tooted, {handle: "matz"}, obj)
350
+
351
+ mailbox1 = res1.context[:subscription_mailbox]
352
+ mailbox2 = res2.context[:subscription_mailbox]
353
+ # The anonymous viewer got an update:
354
+ assert_equal "Merry Christmas, here's a new Ruby version", mailbox1.first["data"]["tootWasTooted"]["toot"]["body"]
355
+ # But not matz:
356
+ assert_equal [], mailbox2
357
+ end
358
+
359
+ it "unsubscribes if a `loads:` argument is not found" do
360
+ res = exec_query <<-GRAPHQL
361
+ subscription {
362
+ tootWasTooted(handle: "matz") {
363
+ toot { body }
364
+ }
365
+ }
366
+ GRAPHQL
367
+ assert_equal 1, in_memory_subscription_count
368
+ obj = OpenStruct.new(toot: { body: "I am a C programmer" }, user: SubscriptionFieldSchema::USERS["matz"])
369
+ SubscriptionFieldSchema.subscriptions.trigger(:toot_was_tooted, {handle: "matz"}, obj)
370
+
371
+ # Get 1 successful update
372
+ mailbox = res.context[:subscription_mailbox]
373
+ assert_equal 1, mailbox.size
374
+ update_payload = mailbox.first
375
+ assert_equal "I am a C programmer", update_payload["data"]["tootWasTooted"]["toot"]["body"]
376
+
377
+ # Then cause a not-found and update again
378
+ matz = SubscriptionFieldSchema::USERS.delete("matz")
379
+ obj = OpenStruct.new(toot: { body: "Merry Christmas, here's a new Ruby version" }, user: matz)
380
+ SubscriptionFieldSchema.subscriptions.trigger(:toot_was_tooted, {handle: "matz"}, obj)
381
+ # there was no subsequent update
382
+ assert_equal 1, mailbox.size
383
+ # The database was cleaned up
384
+ assert_equal 0, in_memory_subscription_count
385
+ end
386
+
387
+ it "sends an error if `#authorized?` fails" do
388
+ res = exec_query <<-GRAPHQL
389
+ subscription {
390
+ tootWasTooted(handle: "matz") {
391
+ toot { body }
392
+ }
393
+ }
394
+ GRAPHQL
395
+ assert_equal 1, in_memory_subscription_count
396
+ matz = SubscriptionFieldSchema::USERS["matz"]
397
+ obj = OpenStruct.new(toot: { body: "I am a C programmer" }, user: matz)
398
+ SubscriptionFieldSchema.subscriptions.trigger(:toot_was_tooted, {handle: "matz"}, obj)
399
+
400
+ # Get 1 successful update
401
+ mailbox = res.context[:subscription_mailbox]
402
+ assert_equal 1, mailbox.size
403
+ update_payload = mailbox.first
404
+ assert_equal "I am a C programmer", update_payload["data"]["tootWasTooted"]["toot"]["body"]
405
+
406
+ # Cause an authorized failure
407
+ matz[:private] = true
408
+ obj = OpenStruct.new(toot: { body: "Merry Christmas, here's a new Ruby version" }, user: matz)
409
+ SubscriptionFieldSchema.subscriptions.trigger(:toot_was_tooted, {handle: "matz"}, obj)
410
+ assert_equal 2, mailbox.size
411
+ assert_equal ["Can't subscribe to private user"], mailbox.last["errors"].map { |e| e["message"] }
412
+ # The subscription remains in place
413
+ assert_equal 1, in_memory_subscription_count
414
+ end
415
+ end
416
+ end