rspec-sse-matchers 0.0.1

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,554 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "matchers/version"
4
+ require "event_stream_parser"
5
+ require "json"
6
+
7
+ # @rbs!
8
+ # type ssePayload = {type: String?, data: String, id: String?, retry: Integer}
9
+
10
+ module RSpec
11
+ module Matchers
12
+ # Matches if the response body ends with "\n\n" (SSE graceful close)
13
+ # @rbs return: RSpec::SSE::Matchers::BeGracefullyClosed
14
+ def be_gracefully_closed
15
+ RSpec::SSE::Matchers::BeGracefullyClosed.new
16
+ end
17
+
18
+ # Matches if the response indicates successfully SSE connection opened
19
+ #
20
+ # @rbs return: RSpec::SSE::Matchers::BeSuccessfullyOpened
21
+ def be_successfully_opened
22
+ RSpec::SSE::Matchers::BeSuccessfullyOpened.new
23
+ end
24
+
25
+ # Matches if the response's events match the expected events in order
26
+ #
27
+ # @rbs *events: ssePayload | Array[ssePayload]
28
+ # @rbs json: bool
29
+ # @rbs return: RSpec::SSE::Matchers::BeEvents
30
+ def be_events(*events, json: false)
31
+ RSpec::SSE::Matchers::BeEvents.new(events.flatten, json:)
32
+ end
33
+
34
+ # Matches if the response's event types match the expected types in order
35
+ #
36
+ # @rbs *types: String | Array[String]
37
+ # @rbs return: RSpec::SSE::Matchers::BeEventTypes
38
+ def be_event_types(*types)
39
+ RSpec::SSE::Matchers::BeEventTypes.new(types.flatten)
40
+ end
41
+
42
+ # Matches if the response's event data match the expected data in order
43
+ #
44
+ # @rbs *data: String | Array[String] | Array[Hash[String, untyped]]
45
+ # @rbs json: bool
46
+ # @rbs return: RSpec::SSE::Matchers::BeEventData
47
+ def be_event_data(*data, json: false)
48
+ RSpec::SSE::Matchers::BeEventData.new(data.flatten, json:)
49
+ end
50
+
51
+ # Matches if the response's event IDs match the expected IDs in order
52
+ #
53
+ # @rbs *ids: String
54
+ # @rbs return: RSpec::SSE::Matchers::BeEventIds
55
+ def be_event_ids(*ids)
56
+ RSpec::SSE::Matchers::BeEventIds.new(ids.flatten)
57
+ end
58
+
59
+ # Matches if the response's reconnection times match the expected times in order
60
+ #
61
+ # @rbs *times: Integer | Array[Integer]
62
+ # @rbs return: RSpec::SSE::Matchers::BeReconnectionTimes
63
+ def be_reconnection_times(*times)
64
+ RSpec::SSE::Matchers::BeReconnectionTimes.new(times.flatten)
65
+ end
66
+
67
+ # Matches if the response's events contain the expected events regardless of order
68
+ #
69
+ # @rbs *events: ssePayload | Array[ssePayload]
70
+ # @rbs json: bool
71
+ # @rbs return: RSpec::SSE::Matchers::ContainExactlyEvents
72
+ def contain_exactly_events(*events, json: false)
73
+ RSpec::SSE::Matchers::ContainExactlyEvents.new(events.flatten, json:)
74
+ end
75
+
76
+ # Matches if the response's event types contain the expected types regardless of order
77
+ #
78
+ # @rbs *types: String | Array[String]
79
+ # @rbs return: RSpec::SSE::Matchers::ContainExactlyEventTypes
80
+ def contain_exactly_event_types(*types)
81
+ RSpec::SSE::Matchers::ContainExactlyEventTypes.new(types.flatten)
82
+ end
83
+
84
+ # Matches if the response's event data contain the expected data regardless of order
85
+ #
86
+ # @rbs *data: String | Array[String] | Array[Hash[String, untyped]]
87
+ # @rbs json: bool
88
+ # @rbs return: RSpec::SSE::Matchers::ContainExactlyEventData
89
+ def contain_exactly_event_data(*data, json: false)
90
+ RSpec::SSE::Matchers::ContainExactlyEventData.new(data.flatten, json:)
91
+ end
92
+
93
+ # Matches if the response's event IDs contain the expected IDs regardless of order
94
+ #
95
+ # @rbs *ids: String | Array[String]
96
+ # @rbs return: RSpec::SSE::Matchers::ContainExactlyEventIds
97
+ def contain_exactly_event_ids(*ids)
98
+ RSpec::SSE::Matchers::ContainExactlyEventIds.new(ids.flatten)
99
+ end
100
+
101
+ # Matches if the response's reconnection times contain the expected times regardless of order
102
+ #
103
+ # @rbs *times: Integer | Array[Integer]
104
+ # @rbs return: RSpec::SSE::Matchers::ContainExactlyReconnectionTimes
105
+ def contain_exactly_reconnection_times(*times)
106
+ RSpec::SSE::Matchers::ContainExactlyReconnectionTimes.new(times.flatten)
107
+ end
108
+
109
+ # Matches if the response's events include all the expected events
110
+ #
111
+ # @rbs *events: ssePayload
112
+ # @rbs json: bool
113
+ # @rbs return: RSpec::SSE::Matchers::HaveEvents
114
+ def have_events(*events, json: false)
115
+ RSpec::SSE::Matchers::HaveEvents.new(events.flatten, json:)
116
+ end
117
+
118
+ # Matches if the response's event types include all the expected types
119
+ #
120
+ # @rbs *types: String | Array[String]
121
+ # @rbs return: RSpec::SSE::Matchers::HaveEventTypes
122
+ def have_event_types(*types)
123
+ RSpec::SSE::Matchers::HaveEventTypes.new(types.flatten)
124
+ end
125
+
126
+ # Matches if the response's event data include all the expected data
127
+ #
128
+ # @rbs *data: String | Array[String] | Array[Hash[String, untyped]]
129
+ # @rbs json: bool
130
+ # @rbs return: RSpec::SSE::Matchers::HaveEventData
131
+ def have_event_data(*data, json: false)
132
+ RSpec::SSE::Matchers::HaveEventData.new(data.flatten, json:)
133
+ end
134
+
135
+ # Matches if the response's event IDs include all the expected IDs
136
+ #
137
+ # @rbs *ids: String
138
+ # @rbs return: RSpec::SSE::Matchers::HaveEventIds
139
+ def have_event_ids(*ids)
140
+ RSpec::SSE::Matchers::HaveEventIds.new(ids.flatten)
141
+ end
142
+
143
+ # Matches if the response's reconnection times include all the expected times
144
+ #
145
+ # @rbs *times: Integer
146
+ # @rbs return: RSpec::SSE::Matchers::HaveReconnectionTimes
147
+ def have_reconnection_times(*times)
148
+ RSpec::SSE::Matchers::HaveReconnectionTimes.new(times.flatten)
149
+ end
150
+ end
151
+ end
152
+
153
+ module RSpec
154
+ module SSE
155
+ module Matchers
156
+ class SseParser
157
+ # @rbs body: String
158
+ # @rbs return: Array[ssePayload]
159
+ def self.parse(body)
160
+ events = []
161
+ EventStreamParser::Parser.new.feed(body) do |type, data, id, reconnection_time|
162
+ events << {
163
+ type:,
164
+ data:,
165
+ id:,
166
+ retry: reconnection_time
167
+ }
168
+ end
169
+ events
170
+ end
171
+ end
172
+
173
+ class BaseMatcher
174
+ # @rbs @expected: Array[Object]
175
+ # @rbs @actual: Object
176
+ # @rbs @parsed_events: Array[ssePayload]
177
+ # @rbs @json: bool
178
+
179
+ # Initialize the matcher with expected values
180
+ #
181
+ # @rbs expected: Array[Object]
182
+ # @rbs return: RSpec::SSE::Matchers::BaseMatcher
183
+ def initialize(expected, json: false)
184
+ @expected = expected
185
+ @json = json
186
+ end
187
+
188
+ # @rbs actual: Object
189
+ # @rbs return: bool
190
+ def matches?(actual)
191
+ @actual = actual
192
+ @parsed_events = SseParser.parse(actual.body)
193
+ match_condition
194
+ end
195
+
196
+ # @rbs return: String
197
+ def failure_message
198
+ "Expected #{description_for(@actual)} to #{description}"
199
+ end
200
+
201
+ # @rbs return: String
202
+ def failure_message_when_negated
203
+ "Expected #{description_for(@actual)} not to #{description}"
204
+ end
205
+
206
+ private
207
+
208
+ # @rbs obj: Object
209
+ # @rbs return: String
210
+ def description_for(obj)
211
+ return "response with events #{extract_actual.inspect}" if obj.respond_to?(:body)
212
+ obj.inspect
213
+ end
214
+
215
+ # @rbs return: Array[Object]
216
+ def extract_actual
217
+ # JSON parsing is enabled if `json: true` is passed
218
+ if @json
219
+ @parsed_events.map do |event|
220
+ parsed_event = event.dup
221
+ parsed_event[:data] = JSON.parse(event[:data])
222
+ parsed_event
223
+ end
224
+ else
225
+ @parsed_events
226
+ end
227
+ end
228
+
229
+ # @rbs return: String
230
+ def description
231
+ "match #{@expected.inspect}"
232
+ end
233
+
234
+ # @rbs return: bool
235
+ def match_condition
236
+ raise NotImplementedError, "Subclasses must implement match_condition"
237
+ end
238
+ end
239
+
240
+ class BeGracefullyClosed
241
+ # @rbs @actual: Object
242
+
243
+ # Match if the response body ends with "\n\n" (SSE graceful close)
244
+ #
245
+ # @rbs actual: Object
246
+ # @rbs return: bool
247
+ def matches?(actual)
248
+ @actual = actual
249
+ @actual.body.end_with?("\n\n")
250
+ end
251
+
252
+ # @rbs return: String
253
+ def failure_message
254
+ 'Expected response body to end with "\\n\\n" (SSE graceful close)'
255
+ end
256
+
257
+ # @rbs return: String
258
+ def failure_message_when_negated
259
+ 'Expected response body not to end with "\\n\\n" (SSE graceful close)'
260
+ end
261
+
262
+ # @rbs return: String
263
+ def description
264
+ "be gracefully closed"
265
+ end
266
+ end
267
+
268
+ class BeSuccessfullyOpened
269
+ # @rbs @actual: Object
270
+
271
+ # Match if the header and status code indicate a successful SSE connection
272
+ #
273
+ # @rbs actual: Object
274
+ # @rbs return: bool
275
+ def matches?(actual)
276
+ @actual = actual
277
+ @actual.headers["content-type"] == "text/event-stream" \
278
+ && @actual.headers["cache-control"]&.match(/no-store/) \
279
+ && @actual.headers["content-length"].nil? \
280
+ && @actual.status == 200
281
+ end
282
+
283
+ # @rbs return: String
284
+ def failure_message
285
+ "Expected response header of `content-type` is `text/event-stream`, `cache-control` contains `no-store`, `content-length` does not exist, and status code is `2xx`"
286
+ end
287
+
288
+ # @rbs return: String
289
+ def failure_message_when_negated
290
+ "Expected response header of `content-type` is not `text/event-stream`, `cache-control` does not contain `no-store`, `content-length` exists, and/or status code is not `2xx`"
291
+ end
292
+
293
+ # @rbs return: String
294
+ def description
295
+ "be successfully opened"
296
+ end
297
+ end
298
+
299
+ class ExactMatcher < BaseMatcher
300
+ # Match if extracted actual values exactly match expected values
301
+ #
302
+ # @rbs return: bool
303
+ def match_condition
304
+ extract_actual == @expected
305
+ end
306
+
307
+ # @rbs return: String
308
+ def description
309
+ "exactly match #{@expected.inspect}"
310
+ end
311
+ end
312
+
313
+ # Base matcher for contain exactly matching (order doesn't matter)
314
+ class ContainExactlyMatcher < BaseMatcher
315
+ # Match if extracted actual values match expected values in any order
316
+ #
317
+ # @rbs return: bool
318
+ def match_condition
319
+ (extract_actual - @expected).empty? && (@expected - extract_actual).empty? && extract_actual.size == @expected.size
320
+ end
321
+
322
+ # @rbs return: String
323
+ def description
324
+ "contain exactly #{@expected.inspect} in any order"
325
+ end
326
+ end
327
+
328
+ # Base matcher for inclusion matching (subset)
329
+ class IncludeMatcher < BaseMatcher
330
+ # Match if extracted actual values include all expected values
331
+ #
332
+ # @rbs return: bool
333
+ def match_condition
334
+ @expected.all? { |expected_item| extract_actual.include?(expected_item) }
335
+ end
336
+
337
+ # @rbs return: String
338
+ def description
339
+ "include #{@expected.inspect}"
340
+ end
341
+ end
342
+
343
+ # Events matchers
344
+
345
+ # Matcher for be_events
346
+ class BeEvents < ExactMatcher
347
+ end
348
+
349
+ # Matcher for contain_exactly_events
350
+ class ContainExactlyEvents < ContainExactlyMatcher
351
+ end
352
+
353
+ # Matcher for have_events
354
+ class HaveEvents < IncludeMatcher
355
+ end
356
+
357
+ module TypeExtractor
358
+ private
359
+
360
+ # Extract event types from parsed events
361
+ #
362
+ # @rbs return: Array[String|nil]
363
+ def extract_actual
364
+ @parsed_events.map { |event| event[:type] }
365
+ end
366
+ end
367
+
368
+ # Matcher for be_event_types
369
+ class BeEventTypes < ExactMatcher
370
+ include TypeExtractor
371
+
372
+ private
373
+
374
+ # @rbs return: String
375
+ def description
376
+ "have event types exactly matching #{@expected.inspect}"
377
+ end
378
+ end
379
+
380
+ # Matcher for contain_exactly_event_types
381
+ class ContainExactlyEventTypes < ContainExactlyMatcher
382
+ include TypeExtractor
383
+
384
+ private
385
+
386
+ # @rbs return: String
387
+ def description
388
+ "contain exactly event types #{@expected.inspect} in any order"
389
+ end
390
+ end
391
+
392
+ # Matcher for have_event_types
393
+ class HaveEventTypes < IncludeMatcher
394
+ include TypeExtractor
395
+
396
+ private
397
+
398
+ # @rbs return: String
399
+ def description
400
+ "include event types #{@expected.inspect}"
401
+ end
402
+ end
403
+
404
+ module DataExtractor
405
+ private
406
+
407
+ # Extract event data from parsed events
408
+ # If :json option is enabled, attempt to parse the data as JSON
409
+ #
410
+ # @rbs return: Array[String|Hash[String, untyped]]
411
+ def extract_actual
412
+ @parsed_events.map do |event|
413
+ # JSON parsing is enabled if json: true is passed
414
+ if @json
415
+ JSON.parse(event[:data])
416
+ else
417
+ event[:data]
418
+ end
419
+ end
420
+ end
421
+ end
422
+
423
+ # Matcher for be_event_data
424
+ class BeEventData < ExactMatcher
425
+ include DataExtractor
426
+
427
+ private
428
+
429
+ # @rbs return: String
430
+ def description
431
+ "have event data exactly matching #{@expected.inspect}"
432
+ end
433
+ end
434
+
435
+ # Matcher for contain_exactly_event_data
436
+ class ContainExactlyEventData < ContainExactlyMatcher
437
+ include DataExtractor
438
+
439
+ private
440
+
441
+ # @rbs return: String
442
+ def description
443
+ "contain exactly event data #{@expected.inspect} in any order"
444
+ end
445
+ end
446
+
447
+ # Matcher for have_event_data
448
+ class HaveEventData < IncludeMatcher
449
+ include DataExtractor
450
+
451
+ private
452
+
453
+ # @rbs return: String
454
+ def description
455
+ "include event data #{@expected.inspect}"
456
+ end
457
+ end
458
+
459
+ module IdExtractor
460
+ private
461
+
462
+ # Extract event IDs from parsed events
463
+ #
464
+ # @rbs return: Array[String|nil]
465
+ def extract_actual
466
+ @parsed_events.map { |event| event[:id] }
467
+ end
468
+ end
469
+
470
+ # Matcher for be_event_ids
471
+ class BeEventIds < ExactMatcher
472
+ include IdExtractor
473
+
474
+ private
475
+
476
+ # @rbs return: String
477
+ def description
478
+ "have event IDs exactly matching #{@expected.inspect}"
479
+ end
480
+ end
481
+
482
+ # Matcher for contain_exactly_event_ids
483
+ class ContainExactlyEventIds < ContainExactlyMatcher
484
+ include IdExtractor
485
+
486
+ private
487
+
488
+ # @rbs return: String
489
+ def description
490
+ "contain exactly event IDs #{@expected.inspect} in any order"
491
+ end
492
+ end
493
+
494
+ # Matcher for have_event_ids
495
+ class HaveEventIds < IncludeMatcher
496
+ include IdExtractor
497
+
498
+ private
499
+
500
+ # @rbs return: String
501
+ def description
502
+ "include event IDs #{@expected.inspect}"
503
+ end
504
+ end
505
+
506
+ module RetryExtractor
507
+ private
508
+
509
+ # Extract reconnection times from parsed events
510
+ #
511
+ # @rbs return: Array[Integer|nil]
512
+ def extract_actual
513
+ @parsed_events.map { |event| event[:retry] }
514
+ end
515
+ end
516
+
517
+ # Matcher for be_reconnection_times
518
+ class BeReconnectionTimes < ExactMatcher
519
+ include RetryExtractor
520
+
521
+ private
522
+
523
+ # @rbs return: String
524
+ def description
525
+ "have reconnection times exactly matching #{@expected.inspect}"
526
+ end
527
+ end
528
+
529
+ # Matcher for contain_exactly_reconnection_times
530
+ class ContainExactlyReconnectionTimes < ContainExactlyMatcher
531
+ include RetryExtractor
532
+
533
+ private
534
+
535
+ # @rbs return: String
536
+ def description
537
+ "contain exactly reconnection times #{@expected.inspect} in any order"
538
+ end
539
+ end
540
+
541
+ # Matcher for have_reconnection_times
542
+ class HaveReconnectionTimes < IncludeMatcher
543
+ include RetryExtractor
544
+
545
+ private
546
+
547
+ # @rbs return: String
548
+ def description
549
+ "include reconnection times #{@expected.inspect}"
550
+ end
551
+ end
552
+ end
553
+ end
554
+ end
data/renovate.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3
+ "extends": [
4
+ "config:recommended"
5
+ ]
6
+ }
@@ -0,0 +1,9 @@
1
+ # Generated from lib/rspec/sse/matchers/version.rb with RBS::Inline
2
+
3
+ module RSpec
4
+ module SSE
5
+ module Matchers
6
+ VERSION: ::String
7
+ end
8
+ end
9
+ end