graphql 1.9.0.pre2 → 1.9.0.pre3

Sign up to get free protection for your applications and to get access to all the features.
@@ -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