cable_room 0.5.4 → 0.5.5

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 606fb175bee9121018da3b32f5ae0e9c8dacfeb0ecc35379e123206c56270d75
4
- data.tar.gz: e97114abe70b45c0d90aee1b9ce8ab0ded6b37a873e327ef5c89296f71e95c94
3
+ metadata.gz: c3adda4804c009f6778b8c629dd3ee94393f8f9329e3472fd155795f4cf6f0ad
4
+ data.tar.gz: 586c39696ce02f18fe83e975e256023151d4e0af8ad6ae9e9a5bb5fdcc6ca829
5
5
  SHA512:
6
- metadata.gz: 9e0f3e9779aa6dc05feb0330fcd39e9493cef279ed5d997295f312a93465236e0441ee5c515f15386960fa1a6746bb8a752446d18928deb3a3f0ba8bc4adc5ca
7
- data.tar.gz: 815b21345cd0268bab7206de951c0d9ca8345a706b108e69abff060bb42b7cec7dcfa8292dab368ca896abc03ad96d8758bde4af57d76e52677e4bf26e013efb
6
+ metadata.gz: 36485ec4fce091883958e09767761cb27cc83c3888c86220c182742a9c319bf9ac21a16f4e0878691bed0071537e7050d01351f7ff42d1401248e21df8788514
7
+ data.tar.gz: aae807731576843f3bcaf0f5f2b7b8da19229f1aac178c687b5191fc7b0852870e4704216ec37dcf8b7323232ff1c432ad3532cb2e19a836f028668106e9801b
@@ -104,7 +104,7 @@ module CableRoom
104
104
  def handle_received_message(message)
105
105
  case message['type']
106
106
  when 'port_connected'
107
- mo = @_port_clients[@current_message_origin] ||= PortClient.new(@current_message_origin)
107
+ mo = @_port_clients[@current_message_origin] ||= PortClient.new(self, @current_message_origin)
108
108
 
109
109
  mo.touch_activity!
110
110
  mo.tag!(message['tags'])
@@ -155,7 +155,8 @@ module CableRoom
155
155
  class PortClient
156
156
  attr_reader :token
157
157
 
158
- def initialize(token)
158
+ def initialize(room, token)
159
+ @room = room
159
160
  @token = token
160
161
  @metadata = HashWithIndifferentAccess.new
161
162
  @metadata[:tags] = Set.new
@@ -164,6 +165,12 @@ module CableRoom
164
165
 
165
166
  delegate :[], :[]=, to: :@metadata
166
167
 
168
+ def <<(data)
169
+ @room.with_port_scope(client_port: @token) do
170
+ @room.broadcast(data)
171
+ end
172
+ end
173
+
167
174
  def merge!(value)
168
175
  @metadata.merge!(value || {})
169
176
  end
@@ -1,3 +1,3 @@
1
1
  module CableRoom
2
- VERSION = "0.5.4".freeze
2
+ VERSION = "0.5.5".freeze
3
3
  end
@@ -0,0 +1,481 @@
1
+ require "spec_helper"
2
+
3
+ # E2E Integration Tests for CableRoom
4
+ #
5
+ # These tests use the async ActionCable adapter (configured in spec_helper.rb)
6
+ # instead of the test adapter to enable real message delivery during tests.
7
+ # This allows us to test actual end-to-end communication flow between rooms
8
+ # and room members, rather than just verifying that broadcasts were sent.
9
+
10
+ RSpec.describe "CableRoom E2E Integration", type: :channel do
11
+ # Setup a test room class for the e2e tests
12
+ class E2ETestRoom < CableRoom::Room::Base
13
+ cattr_accessor :events, default: []
14
+ cattr_accessor :latest_instance
15
+
16
+ after_startup do
17
+ self.class.latest_instance = self
18
+ self.class.events << { type: :room_started, key: key, timestamp: Time.current }
19
+ self << { type: "room_ready", message: "Room is now active" }
20
+ end
21
+
22
+ on_user_joined do
23
+ user_id = message_origin&.user
24
+ self.class.events << { type: :user_joined, user: user_id, key: key, timestamp: Time.current }
25
+ broadcast({ type: "user_joined_broadcast", user: user_id })
26
+ end
27
+
28
+ on_user_left do
29
+ user_id = message_origin&.user
30
+ self.class.events << { type: :user_left, user: user_id, key: key, timestamp: Time.current }
31
+ broadcast({ type: "user_left_broadcast", user: user_id })
32
+ end
33
+
34
+ before_shutdown do
35
+ self.class.events << { type: :room_shutting_down, key: key, timestamp: Time.current }
36
+ end
37
+
38
+ after_shutdown do
39
+ self.class.events << { type: :room_shutdown, key: key, timestamp: Time.current }
40
+ end
41
+
42
+ # Custom message handlers
43
+ def on_custom_message(msg)
44
+ self.class.events << { type: :custom_message_received, data: msg['data'], key: key }
45
+ broadcast({ type: "custom_response", original: msg['data'], processed: true })
46
+ end
47
+
48
+ def on_request_info(msg)
49
+ message_origin << { type: "room_info", key: key, users: connected_users.count }
50
+ end
51
+ end
52
+
53
+ # Setup a test channel for users to join rooms
54
+ class E2ETestChannel < ApplicationCable::Channel
55
+ include CableRoom::RoomMember
56
+
57
+ attr_reader :room, :received_messages
58
+
59
+ def initialize(*)
60
+ super
61
+ @received_messages = []
62
+ end
63
+
64
+ def current_user
65
+ params[:user_id]
66
+ end
67
+
68
+ def subscribed
69
+ key = params[:room_key] || SecureRandom.hex(8)
70
+
71
+ @room = join_room(
72
+ E2ETestRoom,
73
+ key,
74
+ create: params[:create] || false,
75
+ tags: params[:tags] || [],
76
+ as: current_user,
77
+ on_joined: ->(membership) {
78
+ @received_messages << { type: :on_joined_callback, timestamp: Time.current }
79
+ },
80
+ on_room_opened: ->(membership) {
81
+ @received_messages << { type: :on_room_opened_callback, timestamp: Time.current }
82
+ },
83
+ on_room_closed: ->(membership) {
84
+ @received_messages << { type: :on_room_closed_callback, timestamp: Time.current }
85
+ },
86
+ on_message: ->(message) {
87
+ @received_messages << { type: :on_message_callback, message: message, timestamp: Time.current }
88
+ }
89
+ )
90
+ end
91
+
92
+ def unsubscribed
93
+ @room&.leave!
94
+ end
95
+
96
+ def send_custom_message(data)
97
+ @room << { type: 'custom_message', data: data }
98
+ end
99
+
100
+ def request_room_info
101
+ @room << { type: 'request_info' }
102
+ end
103
+ end
104
+
105
+ class ConnStub < ::ActionCable::Channel::ConnectionStub
106
+ delegate :worker_pool, :logger, to: :server
107
+ def initialize(...)
108
+ super
109
+ end
110
+ end
111
+
112
+ module ChannelStub
113
+ def confirmed?
114
+ subscription_confirmation_sent?
115
+ end
116
+
117
+ def rejected?
118
+ subscription_rejected?
119
+ end
120
+
121
+ def streams; super; end
122
+
123
+ # def stream_from(broadcasting, *)
124
+ # super
125
+ # # streams << broadcasting
126
+ # end
127
+
128
+ # def stream_from(broadcasting, callback = nil, coder: nil, &block)
129
+ # broadcasting = String(broadcasting)
130
+
131
+ # # Don't send the confirmation until pubsub#subscribe is successful
132
+ # defer_subscription_confirmation!
133
+
134
+ # # Build a stream handler by wrapping the user-provided callback with a decoder
135
+ # # or defaulting to a JSON-decoding retransmitter.
136
+ # handler = worker_pool_stream_handler(broadcasting, callback || block, coder: coder)
137
+ # streams << broadcasting
138
+
139
+ # connection.server.event_loop.post do
140
+ # pubsub.subscribe(broadcasting, handler, lambda do
141
+ # ensure_confirmation_sent
142
+ # logger.info "#{self.class.name} is streaming from #{broadcasting}"
143
+ # end)
144
+ # end
145
+ # end
146
+
147
+ # def stop_all_streams
148
+ # @_streams = []
149
+ # end
150
+
151
+ # def streams
152
+ # @_streams ||= []
153
+ # end
154
+ end
155
+
156
+ def subscribe(params={})
157
+ @connection ||= stub_connection
158
+ @subscription = self.class.channel_class.new(connection, "test_stub", params.with_indifferent_access)
159
+ @subscription.singleton_class.include(ChannelStub)
160
+ @subscription.subscribe_to_channel
161
+ @subscription
162
+ end
163
+
164
+ before(:each) do
165
+ @connection = ConnStub.new
166
+ end
167
+
168
+ describe E2ETestChannel, type: :channel do
169
+ let(:room_key) { "e2e-test-#{SecureRandom.hex(4)}" }
170
+
171
+ before(:each) do
172
+ E2ETestRoom.events.clear
173
+ E2ETestRoom.latest_instance = nil
174
+ end
175
+
176
+ describe "Room Creation and Lifecycle" do
177
+ it "creates a room when a user connects with create: true" do
178
+ expect {
179
+ subscribe(room_key: room_key, create: true, user_id: "user1")
180
+ }.to change { CableRoom::ChannelTracker.instance.room_channels.count }.by(1)
181
+
182
+ expect(subscription).to be_confirmed
183
+ expect(E2ETestRoom.events).to include(hash_including(type: :room_started, key: room_key))
184
+ end
185
+
186
+ it "does not create a room when create: false" do
187
+ expect {
188
+ subscribe(room_key: room_key, create: false, user_id: "user1")
189
+ }.not_to change { CableRoom::ChannelTracker.instance.room_channels.count }
190
+ end
191
+
192
+ it "properly shuts down a room when sent KILL message" do
193
+ subscribe(room_key: room_key, create: true, user_id: "user1")
194
+
195
+ initial_count = CableRoom::ChannelTracker.instance.room_channels.count
196
+
197
+ E2ETestRoom.send_message(room_key, "KILL", port: :to_room)
198
+ sleep 0.2 # Give time for shutdown to complete
199
+
200
+ expect(CableRoom::ChannelTracker.instance.room_channels.count).to eq(initial_count - 1)
201
+ expect(E2ETestRoom.events).to include(hash_including(type: :room_shutdown, key: room_key))
202
+ end
203
+ end
204
+
205
+ describe "Port Connections" do
206
+ it "establishes proper port connections for room communication" do
207
+ subscribe(room_key: room_key, create: true, user_id: "user1")
208
+
209
+ # Verify the expected streams are opened
210
+ expect(subscription.streams.keys).to include(/E2ETestRoom:.*:from_room/)
211
+ expect(subscription.streams.keys).to include(/E2ETestRoom:.*:[0-9a-f]{32}/) # Token-based port
212
+ end
213
+
214
+ it "creates separate port streams for different users" do
215
+ # First user subscribes
216
+ subscribe(room_key: room_key, create: true, user_id: "user1")
217
+ first_subscription = subscription
218
+
219
+ # Second user subscribes to same room
220
+ subscribe(room_key: room_key, create: false, user_id: "user2")
221
+ second_subscription = subscription
222
+
223
+ # Should have the shared broadcast stream
224
+ expect(first_subscription.streams.keys).to include(/E2ETestRoom:.*:from_room/)
225
+ expect(second_subscription.streams.keys).to include(/E2ETestRoom:.*:from_room/)
226
+ end
227
+
228
+ it "supports tagged ports for selective broadcasting" do
229
+ subscribe(room_key: room_key, create: true, user_id: "user1", tags: ["admin", "moderator"])
230
+
231
+ # Give time for connection to establish
232
+ sleep 0.1
233
+
234
+ # User should be listening to tagged ports
235
+ room = E2ETestRoom.latest_instance
236
+ expect(room).not_to be_nil
237
+ end
238
+ end
239
+
240
+ describe "User Joining and Leaving" do
241
+ it "properly handles a user joining the room" do
242
+ subscribe(room_key: room_key, create: true, user_id: "user1")
243
+
244
+ # Give time for join callbacks to fire
245
+ sleep 0.1
246
+
247
+ expect(E2ETestRoom.events).to include(hash_including(type: :user_joined, user: "user1"))
248
+ expect(subscription.received_messages).to include(hash_including(type: :on_joined_callback))
249
+ end
250
+
251
+ it "handles multiple users joining the same room" do
252
+ # First user joins and creates room
253
+ subscribe(room_key: room_key, create: true, user_id: "user1")
254
+ sleep 0.1
255
+
256
+ first_user_count = E2ETestRoom.events.count { |e| e[:type] == :user_joined }
257
+
258
+ # Second user joins existing room
259
+ subscribe(room_key: room_key, create: false, user_id: "user2")
260
+ sleep 0.1
261
+
262
+ # Should have two user_joined events
263
+ expect(E2ETestRoom.events.count { |e| e[:type] == :user_joined }).to eq(first_user_count + 1)
264
+
265
+ # Verify both users are tracked
266
+ expect(E2ETestRoom.events.map { |e| e[:user] if e[:type] == :user_joined }.compact).to include("user1", "user2")
267
+ end
268
+
269
+ it "properly handles a user leaving the room" do
270
+ subscribe(room_key: room_key, create: true, user_id: "user1")
271
+ sleep 0.1
272
+
273
+ # User leaves
274
+ unsubscribe
275
+ sleep 0.1
276
+
277
+ expect(E2ETestRoom.events).to include(hash_including(type: :user_left, user: "user1"))
278
+ end
279
+
280
+ it "handles same user connecting from multiple ports" do
281
+ # Same user subscribes twice (e.g., multiple browser tabs)
282
+ subscribe(room_key: room_key, create: true, user_id: "user1")
283
+ first_subscription = subscription
284
+ sleep 0.1
285
+
286
+ initial_join_count = E2ETestRoom.events.count { |e| e[:type] == :user_joined }
287
+
288
+ subscribe(room_key: room_key, create: false, user_id: "user1")
289
+ sleep 0.1
290
+
291
+ # Should only trigger one user_joined event (same user)
292
+ expect(E2ETestRoom.events.count { |e| e[:type] == :user_joined }).to eq(initial_join_count)
293
+
294
+ # Close one connection
295
+ first_subscription.unsubscribe_from_channel
296
+ sleep 0.1
297
+
298
+ # User should not have left yet (still has one connection)
299
+ final_left_count = E2ETestRoom.events.count { |e| e[:type] == :user_left && e[:user] == "user1" }
300
+ expect(final_left_count).to eq(0)
301
+ end
302
+ end
303
+
304
+ describe "Message Broadcasting and Communication" do
305
+ it "broadcasts messages to all connected users" do
306
+ # Two users join
307
+ subscribe(room_key: room_key, create: true, user_id: "user1")
308
+ user1 = subscription
309
+ sleep 0.1
310
+
311
+ subscribe(room_key: room_key, create: false, user_id: "user2")
312
+ user2 = subscription
313
+ sleep 0.1
314
+
315
+ # User 1 sends a custom message
316
+ user1.send_custom_message({ text: "Hello everyone!" })
317
+ sleep 0.1
318
+
319
+ # Both users should receive the broadcast response
320
+ expect(user1.received_messages).to include(
321
+ hash_including(
322
+ type: :on_message_callback,
323
+ message: hash_including("type" => "custom_response", "processed" => true)
324
+ )
325
+ )
326
+ expect(user2.received_messages).to include(
327
+ hash_including(
328
+ type: :on_message_callback,
329
+ message: hash_including("type" => "custom_response", "processed" => true)
330
+ )
331
+ )
332
+
333
+ # Room should have recorded the custom message
334
+ expect(E2ETestRoom.events).to include(
335
+ hash_including(type: :custom_message_received, data: { "text" => "Hello everyone!" })
336
+ )
337
+ end
338
+
339
+ it "supports direct messaging to specific users via their port" do
340
+ subscribe(room_key: room_key, create: true, user_id: "user1")
341
+ user1 = subscription
342
+ sleep 0.1
343
+
344
+ subscribe(room_key: room_key, create: false, user_id: "user2")
345
+ user2 = subscription
346
+ sleep 0.1
347
+
348
+ # User 2 requests room info (should get private response)
349
+ user2.request_room_info
350
+ sleep 0.1
351
+
352
+ # User 2 should receive room info
353
+ expect(user2.received_messages).to include(
354
+ hash_including(
355
+ type: :on_message_callback,
356
+ message: hash_including("type" => "room_info")
357
+ )
358
+ )
359
+ end
360
+ end
361
+
362
+ describe "Complete E2E Flow" do
363
+ it "handles full lifecycle: create, multiple users join/leave, messages, shutdown" do
364
+ # Step 1: First user creates room
365
+ subscribe(room_key: room_key, create: true, user_id: "alice")
366
+ alice = subscription
367
+ sleep 0.1
368
+
369
+ expect(CableRoom::ChannelTracker.instance.room_channels.count).to be > 0
370
+ expect(E2ETestRoom.events).to include(hash_including(type: :room_started))
371
+ expect(E2ETestRoom.events).to include(hash_including(type: :user_joined, user: "alice"))
372
+
373
+ # Step 2: Second user joins
374
+ subscribe(room_key: room_key, create: false, user_id: "bob")
375
+ bob = subscription
376
+ sleep 0.1
377
+
378
+ expect(E2ETestRoom.events).to include(hash_including(type: :user_joined, user: "bob"))
379
+
380
+ # Step 3: Third user joins
381
+ subscribe(room_key: room_key, create: false, user_id: "charlie")
382
+ charlie = subscription
383
+ sleep 0.1
384
+
385
+ expect(E2ETestRoom.events).to include(hash_including(type: :user_joined, user: "charlie"))
386
+
387
+ # Step 4: Users exchange messages
388
+ alice.send_custom_message({ from: "alice", text: "Hello room!" })
389
+ sleep 0.1
390
+
391
+ bob.send_custom_message({ from: "bob", text: "Hi alice!" })
392
+ sleep 0.1
393
+
394
+ # All users should have received broadcasts
395
+ expect(alice.received_messages.count { |m| m[:type] == :on_message_callback && m[:message]["type"] == "custom_response" }).to eq(2)
396
+ expect(bob.received_messages.count { |m| m[:type] == :on_message_callback && m[:message]["type"] == "custom_response" }).to eq(2)
397
+ expect(charlie.received_messages.count { |m| m[:type] == :on_message_callback && m[:message]["type"] == "custom_response" }).to eq(2)
398
+
399
+ # Step 5: One user leaves
400
+ bob.unsubscribe_from_channel
401
+ sleep 0.1
402
+
403
+ expect(E2ETestRoom.events).to include(hash_including(type: :user_left, user: "bob"))
404
+
405
+ # Step 6: Another message is sent (bob shouldn't receive it)
406
+ bob_message_count_before = bob.received_messages.count
407
+ alice.send_custom_message({ from: "alice", text: "Bob left!" })
408
+ sleep 0.1
409
+
410
+ expect(bob.received_messages.count).to eq(bob_message_count_before) # No new messages for bob
411
+ expect(charlie.received_messages.count { |m| m[:type] == :on_message_callback && m[:message]["type"] == "custom_response" }).to eq(3)
412
+
413
+ # Step 7: Shutdown the room
414
+ room_count_before = CableRoom::ChannelTracker.instance.room_channels.count
415
+ E2ETestRoom.send_message(room_key, "KILL", port: :to_room)
416
+ sleep 0.2
417
+
418
+ # Room should be shut down
419
+ expect(CableRoom::ChannelTracker.instance.room_channels.count).to eq(room_count_before - 1)
420
+ expect(E2ETestRoom.events).to include(hash_including(type: :room_shutting_down))
421
+ expect(E2ETestRoom.events).to include(hash_including(type: :room_shutdown))
422
+
423
+ # Users should receive room_closed notifications
424
+ expect(alice.received_messages).to include(hash_including(type: :on_room_closed_callback))
425
+ expect(charlie.received_messages).to include(hash_including(type: :on_room_closed_callback))
426
+ end
427
+ end
428
+
429
+ describe "Room Reaping and Idle Timeout" do
430
+ it "automatically shuts down idle rooms" do
431
+ subscribe(room_key: room_key, create: true, user_id: "user1")
432
+ room = E2ETestRoom.latest_instance
433
+ vchan = room.instance_variable_get(:@cable_channel)
434
+
435
+ unsubscribe
436
+ sleep 0.1
437
+
438
+ # Room should still exist (hasn't timed out yet)
439
+ expect(CableRoom::ChannelTracker.instance.room_channels).to include(vchan)
440
+
441
+ # Simulate watchdog timeout
442
+ vchan.instance_variable_set(:@last_watchdog_ping_at, 2.hours.ago)
443
+
444
+ room_count_before = CableRoom::ChannelTracker.instance.room_channels.count
445
+ vchan.send(:check_room_watchdog)
446
+ sleep 0.2
447
+
448
+ # Room should be reaped
449
+ expect(CableRoom::ChannelTracker.instance.room_channels.count).to eq(room_count_before - 1)
450
+ expect(E2ETestRoom.events).to include(hash_including(type: :room_shutdown))
451
+ end
452
+ end
453
+
454
+ describe "Concurrent Room Operations" do
455
+ it "handles multiple rooms simultaneously" do
456
+ room1_key = "concurrent-room-1-#{SecureRandom.hex(4)}"
457
+ room2_key = "concurrent-room-2-#{SecureRandom.hex(4)}"
458
+
459
+ # Create two separate rooms
460
+ subscribe(room_key: room1_key, create: true, user_id: "user1")
461
+ room1_sub = subscription
462
+ sleep 0.1
463
+
464
+ subscribe(room_key: room2_key, create: true, user_id: "user2")
465
+ room2_sub = subscription
466
+ sleep 0.1
467
+
468
+ # Both rooms should exist
469
+ expect(E2ETestRoom.events.count { |e| e[:type] == :room_started }).to be >= 2
470
+
471
+ # Send messages in both rooms
472
+ room1_sub.send_custom_message({ room: "room1" })
473
+ room2_sub.send_custom_message({ room: "room2" })
474
+ sleep 0.1
475
+
476
+ # Each room should have processed its own message
477
+ expect(E2ETestRoom.events.count { |e| e[:type] == :custom_message_received }).to eq(2)
478
+ end
479
+ end
480
+ end
481
+ end
@@ -1,2 +1,2 @@
1
1
  test:
2
- adapter: test
2
+ adapter: async