lib/submodules/ably-ruby/spec/acceptance/realtime/channel_spec.rb in ably-rest-0.9.3 vs lib/submodules/ably-ruby/spec/acceptance/realtime/channel_spec.rb in ably-rest-1.0.0

- old
+ new

@@ -5,15 +5,20 @@ vary_by_protocol do let(:default_options) { { key: api_key, environment: environment, protocol: protocol } } let(:client_options) { default_options } let(:client) { auto_close Ably::Realtime::Client.new(client_options) } + let(:connection) { client.connection } let(:channel_name) { random_str } let(:payload) { random_str } let(:channel) { client.channel(channel_name) } let(:messages) { [] } + def disconnect_transport + connection.transport.unbind + end + describe 'initialization' do context 'with :auto_connect option set to false on connection' do let(:client) do auto_close Ably::Realtime::Client.new(default_options.merge(auto_connect: false)) end @@ -34,85 +39,204 @@ end end end describe '#attach' do - it 'emits attaching then attached events' do - channel.once(:attaching) do - channel.once(:attached) do - stop_reactor + context 'when initialized' do + it 'emits attaching then attached events' do + channel.once(:attaching) do + channel.once(:attached) do + stop_reactor + end end + + channel.attach end - channel.attach - end + it 'ignores subsequent #attach calls but calls the success callback if provided' do + channel.once(:attaching) do + channel.attach + channel.once(:attached) do + channel.attach do + stop_reactor + end + end + end - it 'ignores subsequent #attach calls but calls the success callback if provided' do - channel.once(:attaching) do channel.attach - channel.once(:attached) do + end + + it 'attaches to a channel' do + channel.attach + channel.on(:attached) do + expect(channel.state).to eq(:attached) + stop_reactor + end + end + + it 'attaches to a channel and calls the provided block (#RTL4d)' do + channel.attach do + expect(channel.state).to eq(:attached) + stop_reactor + end + end + + it 'sends an ATTACH and waits for an ATTACHED (#RTL4c)' do + connection.once(:connected) do + attach_count = 0 + attached_count = 0 + test_complete = false + client.connection.__incoming_protocol_msgbus__.subscribe(:protocol_message) do |protocol_message| + next if test_complete + attached_count += 1 if protocol_message.action == :attached + end + client.connection.__outgoing_protocol_msgbus__.subscribe(:protocol_message) do |protocol_message| + next if test_complete + attach_count += 1 if protocol_message.action == :attach + end channel.attach do - stop_reactor + EventMachine.add_timer(1) do + test_complete = true + expect(attach_count).to eql(1) + expect(attached_count).to eql(1) + stop_reactor + end end end end - channel.attach - end - - it 'attaches to a channel' do - channel.attach - channel.on(:attached) do - expect(channel.state).to eq(:attached) - stop_reactor + it 'implicitly attaches the channel (#RTL7c)' do + expect(channel).to be_initialized + channel.subscribe { |message| } + channel.once(:attached) do + stop_reactor + end end - end - it 'attaches to a channel and calls the provided block' do - channel.attach do - expect(channel.state).to eq(:attached) - stop_reactor + context 'when the implicit channel attach fails' do + let(:allowed_params) do + { capability: { "*" => ["*"] } } + end + let(:not_allowed_params) do + { capability: { "only_this_channel" => ["*"] } } + end + let(:client_options) { default_options.merge(default_token_params: not_allowed_params, use_token_auth: true, log_level: :fatal) } + + it 'registers the listener anyway (#RTL7c)' do + channel.subscribe do |message| + stop_reactor + end + channel.once(:failed) do + client.auth.authorize(allowed_params) do + channel.attach do + channel.publish 'foo' + end + end + end + end end end it 'returns a SafeDeferrable that catches exceptions in callbacks and logs them' do expect(channel.attach).to be_a(Ably::Util::SafeDeferrable) stop_reactor end - it 'calls the SafeDeferrable callback on success' do + it 'calls the SafeDeferrable callback on success (#RTL4d)' do channel.attach.callback do expect(channel).to be_a(Ably::Realtime::Channel) expect(channel.state).to eq(:attached) stop_reactor end end + context 'when an ATTACHED acknowledge is not received on the current connection' do + # As soon as the client sends the ATTACH on a CONNECTED connection + # simulate a transport failure that triggers the DISCONNECTED state twice + it 'sends another ATTACH each time the connection becomes connected' do + attached_messages = [] + client.connection.__outgoing_protocol_msgbus__.on(:protocol_message) do |protocol_message| + if protocol_message.action == :attach + attached_messages << protocol_message + if attached_messages.count < 3 + EventMachine.next_tick do + disconnect_transport + end + end + end + end + + connection.once(:connected) do + connection.once(:disconnected) do + expect(attached_messages.count).to eql(1) + connection.once(:disconnected) do + expect(attached_messages.count).to eql(2) + connection.once(:connected) do + EventMachine.add_timer(0.1) do + expect(attached_messages.count).to eql(3) + end + end + end + end + channel.attach + end + + channel.once(:attached) do + EventMachine.add_timer(1) do + expect(attached_messages.count).to eql(3) + stop_reactor + end + end + end + end + + context 'when state is :attached' do + it 'does nothing (#RTL4a)' do + channel.attach do + stopping = false + client.connection.__outgoing_protocol_msgbus__.once(:protocol_message) do |protocol_message| + raise "No outgoing messages should be sent as already ATTACHED" unless stopping + end + 5.times do |index| + EventMachine.add_timer(0.2 * index) { channel.attach } + end + EventMachine.add_timer(1.5) do + stopping = true + stop_reactor + end + end + end + end + context 'when state is :failed' do let(:client_options) { default_options.merge(log_level: :fatal) } - it 'reattaches' do + it 'reattaches and sets the errorReason to nil (#RTL4g)' do channel.attach do channel.transition_state_machine :failed, reason: RuntimeError.new expect(channel).to be_failed + expect(channel.error_reason).to_not be_nil channel.attach do expect(channel).to be_attached + expect(channel.error_reason).to be_nil stop_reactor end end end end context 'when state is :detaching' do - it 'moves straight to attaching and skips detached' do + it 'does the attach operation after the completion of the pending request (#RTL4h)' do channel.once(:detaching) do - channel.once(:detached) { raise 'Detach should not have been reached' } - - channel.once(:attaching) do - channel.once(:attached) do - channel.off - stop_reactor + channel.once(:detached) do + channel.once(:attaching) do + channel.once(:attached) do + EventMachine.add_timer(1) do + expect(channel).to be_attached + stop_reactor + end + end end end channel.attach end @@ -145,41 +269,41 @@ end end end context 'failure as a result of insufficient key permissions' do + let(:auth_options) do + default_options.merge( + key: restricted_api_key, + log_level: :fatal, + use_token_auth: true, + # TODO: Use wildcard / default when intersection issue resolved, realtime#780 + default_token_params: { capability: { "canpublish:foo" => ["publish"] } } + ) + end let(:restricted_client) do - auto_close Ably::Realtime::Client.new(default_options.merge(key: restricted_api_key, log_level: :fatal)) + auto_close Ably::Realtime::Client.new(auth_options) end - let(:restricted_channel) { restricted_client.channel("cannot_subscribe") } + let(:restricted_channel) { restricted_client.channel("cansubscribe:foo") } - it 'emits failed event' do + it 'emits failed event (#RTL4e)' do restricted_channel.attach restricted_channel.on(:failed) do |connection_state| expect(restricted_channel.state).to eq(:failed) expect(connection_state.reason.status).to eq(401) stop_reactor end end - it 'calls the errback of the returned Deferrable' do + it 'calls the errback of the returned Deferrable (#RTL4d)' do restricted_channel.attach.errback do |error| expect(restricted_channel.state).to eq(:failed) expect(error.status).to eq(401) stop_reactor end end - it 'emits an error event' do - restricted_channel.attach - restricted_channel.on(:error) do |error| - expect(restricted_channel.state).to eq(:failed) - expect(error.status).to eq(401) - stop_reactor - end - end - it 'updates the error_reason' do restricted_channel.attach restricted_channel.on(:failed) do expect(restricted_channel.error_reason.status).to eq(401) stop_reactor @@ -189,14 +313,12 @@ context 'and subsequent authorisation with suitable permissions' do it 'attaches to the channel successfully and resets the channel error_reason' do restricted_channel.attach restricted_channel.once(:failed) do restricted_client.close do - # A direct call to #authorize is synchronous - restricted_client.auth.authorize({}, key: api_key) - - restricted_client.connect do + token_params = { capability: { "cansubscribe:foo" => ["subscribe"] } } + restricted_client.auth.authorize(token_params) do restricted_channel.once(:attached) do expect(restricted_channel.error_reason).to be_nil stop_reactor end restricted_channel.attach @@ -204,112 +326,183 @@ end end end end end - end - describe '#detach' do - it 'detaches from a channel' do - channel.attach do - channel.detach - channel.on(:detached) do - expect(channel.state).to eq(:detached) + context 'with connection state' do + it 'is initialized (#RTL4i)' do + expect(connection).to be_initialized + channel.attach do stop_reactor end end - end - it 'detaches from a channel and calls the provided block' do - channel.attach do - expect(channel.state).to eq(:attached) - channel.detach do - expect(channel.state).to eq(:detached) - stop_reactor + it 'is connecting (#RTL4i)' do + connection.once(:connecting) do + channel.attach do + stop_reactor + end end end + + it 'is disconnected (#RTL4i)' do + connection.once(:connected) do + connection.once(:disconnected) do + channel.attach do + stop_reactor + end + end + disconnect_transport + end + end end + end - it 'emits :detaching then :detached events' do - channel.once(:detaching) do - channel.once(:detached) do - stop_reactor + describe '#detach' do + context 'when state is :attached' do + it 'it detaches from a channel (#RTL5d)' do + channel.attach do + channel.detach + channel.on(:detached) do + expect(channel.state).to eq(:detached) + stop_reactor + end end end - channel.attach do - channel.detach + it 'detaches from a channel and calls the provided block (#RTL5d, #RTL5e)' do + channel.attach do + expect(channel.state).to eq(:attached) + channel.detach do + expect(channel.state).to eq(:detached) + stop_reactor + end + end end - end - it 'returns a SafeDeferrable that catches exceptions in callbacks and logs them' do - channel.attach do - expect(channel.detach).to be_a(Ably::Util::SafeDeferrable) - stop_reactor + it 'emits :detaching then :detached events' do + channel.once(:detaching) do + channel.once(:detached) do + stop_reactor + end + end + + channel.attach do + channel.detach + end end - end - it 'calls the Deferrable callback on success' do - channel.attach do - channel.detach.callback do - expect(channel).to be_a(Ably::Realtime::Channel) - expect(channel.state).to eq(:detached) + it 'returns a SafeDeferrable that catches exceptions in callbacks and logs them' do + channel.attach do + expect(channel.detach).to be_a(Ably::Util::SafeDeferrable) stop_reactor end end + + it 'calls the Deferrable callback on success' do + channel.attach do + channel.detach.callback do + expect(channel).to be_a(Ably::Realtime::Channel) + expect(channel.state).to eq(:detached) + stop_reactor + end + end + end + + context 'and DETACHED message is not received within realtime request timeout' do + let(:request_timeout) { 2 } + let(:client_options) { default_options.merge(realtime_request_timeout: request_timeout) } + + it 'fails the deferrable and returns to the previous state (#RTL5f, #RTL5e)' do + channel.attach do + # don't process any incoming ProtocolMessages so the channel never becomes detached + connection.__incoming_protocol_msgbus__.unsubscribe + detached_requested_at = Time.now.to_i + channel.detach do + raise "The detach should not succeed if no incoming protocol messages are processed" + end.errback do + expect(channel).to be_attached + expect(Time.now.to_i - detached_requested_at).to be_within(1).of(request_timeout) + stop_reactor + end + end + end + end end context 'when state is :failed' do let(:client_options) { default_options.merge(log_level: :fatal) } - it 'raises an exception' do + it 'fails the deferrable (#RTL5b)' do channel.attach do channel.transition_state_machine :failed, reason: RuntimeError.new expect(channel).to be_failed - expect { channel.detach }.to raise_error Ably::Exceptions::InvalidStateChange - stop_reactor + channel.detach.errback do |error| + expect(error).to be_a(Ably::Exceptions::InvalidStateChange) + stop_reactor + end end end end context 'when state is :attaching' do - it 'moves straight to :detaching state and skips :attached' do - channel.once(:attaching) do - channel.once(:attached) { raise 'Attached should never be reached' } - - channel.once(:detaching) do - channel.once(:detached) do - stop_reactor + it 'waits for the attach to complete and then moves to detached' do + connection.once(:connected) do + channel.once(:attaching) do + reached_attached = false + channel.once(:attached) do + channel.once(:detached) do + stop_reactor + end end + channel.detach end - - channel.detach + channel.attach end - - channel.attach end end context 'when state is :detaching' do - it 'ignores subsequent #detach calls but calls the callback if provided' do + it 'ignores subsequent #detach calls but calls the callback if provided (#RTL5i)' do channel.once(:detaching) do - channel.detach channel.once(:detached) do channel.detach do stop_reactor end end + channel.detach end channel.attach do channel.detach end end end + context 'when state is :suspended' do + it 'moves the channel state immediately to DETACHED state (#RTL5j)' do + channel.attach do + channel.once(:suspended) do + channel.on do |channel_state_change| + expect(channel_state_change.current).to eq(:detached) + expect(channel.state).to eq(:detached) + EventMachine.add_timer(1) do + stop_reactor + end + end + EventMachine.next_tick do + channel.detach + end + end + channel.transition_state_machine :suspended + end + end + end + context 'when state is :initialized' do - it 'does nothing as there is no channel to detach' do + it 'does nothing as there is no channel to detach (#RTL5a)' do expect(channel).to be_initialized channel.detach do expect(channel).to be_initialized stop_reactor end @@ -321,18 +514,214 @@ expect(channel).to be_initialized stop_reactor end end end + + context 'when state is :detached' do + it 'does nothing as the channel is detached (#RTL5a)' do + channel.attach do + channel.detach do + expect(channel).to be_detached + channel.on do + raise "Channel state should not change when calling detached if already detached" + end + channel.detach do + EventMachine.add_timer(1) { stop_reactor } + end + end + end + end + end + + context 'when connection state is' do + context 'closing' do + it 'fails the deferrable (#RTL5b)' do + connection.once(:connected) do + channel.attach do + connection.once(:closing) do + channel.detach.errback do |error| + expect(error).to be_a(Ably::Exceptions::InvalidStateChange) + stop_reactor + end + end + connection.close + end + end + end + end + + context 'failed and channel is failed' do + let(:client_options) do + default_options.merge(log_level: :none) + end + + it 'fails the deferrable (#RTL5b)' do + connection.once(:connected) do + channel.attach do + connection.once(:failed) do + expect(channel).to be_failed + channel.detach.errback do |error| + expect(error).to be_a(Ably::Exceptions::InvalidStateChange) + stop_reactor + end + end + error = Ably::Exceptions::ConnectionFailed.new('forced failure', 500, 50000) + client.connection.manager.error_received_from_server error + end + end + end + end + + context 'failed and channel is detached' do + let(:client_options) do + default_options.merge(log_level: :none) + end + + it 'fails the deferrable (#RTL5b)' do + connection.once(:connected) do + channel.attach do + channel.detach do + connection.once(:failed) do + expect(channel).to be_detached + channel.detach.errback do |error| + expect(error).to be_a(Ably::Exceptions::InvalidStateChange) + stop_reactor + end + end + error = Ably::Exceptions::ConnectionFailed.new('forced failure', 500, 50000) + client.connection.manager.error_received_from_server error + end + end + end + end + end + + context 'initialized' do + it 'does the detach operation once the connection state is connected (#RTL5h)' do + expect(connection).to be_initialized + channel.attach + channel.detach + connection.once(:connected) do + channel.once(:attached) do + channel.once(:detached) do + stop_reactor + end + end + end + end + end + + context 'connecting' do + it 'does the detach operation once the connection state is connected (#RTL5h)' do + connection.once(:connecting) do + channel.attach + channel.detach + connection.once(:connected) do + channel.once(:attached) do + channel.once(:detached) do + stop_reactor + end + end + end + end + end + end + + context 'disconnected' do + let(:client_options) do + default_options.merge(log_level: :fatal) + end + it 'does the detach operation once the connection state is connected (#RTL5h)' do + connection.once(:connected) do + connection.once(:disconnected) do + channel.attach + channel.detach + connection.once(:connected) do + channel.once(:attached) do + channel.once(:detached) do + stop_reactor + end + end + end + end + disconnect_transport + end + end + end + end end - describe 'channel recovery in :attaching state' do - context 'the transport is disconnected before the ATTACHED protocol message is received' do - skip 'attach times out and fails if not ATTACHED protocol message received' - skip 'channel is ATTACHED if ATTACHED protocol message is later received' - skip 'sends an ATTACH protocol message in response to a channel message being received on the attaching channel' + describe 'automatic channel recovery' do + let(:realtime_request_timeout) { 2 } + let(:client_options) do + default_options.merge(realtime_request_timeout: 2, log_level: :fatal) end + + context 'when an ATTACH request times out' do + it 'moves to the SUSPENDED state (#RTL4f)' do + connection.once(:connected) do + attach_request_sent_at = Time.now + channel.attach + client.connection.__incoming_protocol_msgbus__.unsubscribe + channel.once(:suspended) do + expect(attach_request_sent_at.to_i).to be_within(realtime_request_timeout + 1).of(Time.now.to_i) + stop_reactor + end + end + end + end + + context 'if a subsequent ATTACHED is received on an ATTACHED channel' do + it 'ignores the additional ATTACHED if resumed is true (#RTL12)' do + channel.attach do + channel.once do |obj| + fail "No state change expected: #{obj}" + end + attached_message = Ably::Models::ProtocolMessage.new(action: 11, channel: channel_name, flags: 4) # ATTACHED with resumed flag + client.connection.__incoming_protocol_msgbus__.publish :protocol_message, attached_message + EventMachine.add_timer(1) do + channel.off + stop_reactor + end + end + end + + it 'emits an UPDATE only when resumed is true (#RTL12)' do + channel.attach do + expect(channel.error_reason).to be_nil + channel.on(:update) do |state_change| + expect(state_change.current).to eq(:attached) + expect(state_change.previous).to eq(:attached) + expect(state_change.resumed).to be_falsey + expect(state_change.reason).to be_nil + expect(channel.error_reason).to be_nil + stop_reactor + end + attached_message = Ably::Models::ProtocolMessage.new(action: 11, channel: channel_name, flags: 0) # No resumed flag + client.connection.__incoming_protocol_msgbus__.publish :protocol_message, attached_message + end + end + + it 'emits an UPDATE when resumed is true and includes the reason error from the ProtocolMessage (#RTL12)' do + channel.attach do + expect(channel.error_reason).to be_nil + channel.on(:update) do |state_change| + expect(state_change.current).to eq(:attached) + expect(state_change.previous).to eq(:attached) + expect(state_change.resumed).to be_falsey + expect(state_change.reason.code).to eql(50505) + expect(channel.error_reason.code).to eql(50505) + stop_reactor + end + attached_message = Ably::Models::ProtocolMessage.new(action: 11, channel: channel_name, error: { code: 50505 }, flags: 0) # No resumed flag with error + client.connection.__incoming_protocol_msgbus__.publish :protocol_message, attached_message + end + end + end + + # skip 'sends an ATTACH protocol message in response to a channel message being received on the attaching channel' end context '#publish' do let(:name) { random_str } let(:data) { random_str } @@ -379,36 +768,42 @@ context 'with :queue_messages client option set to false' do let(:client_options) { default_options.merge(queue_messages: false) } context 'and connection state initialized' do - it 'raises an exception' do - expect { channel.publish('event') }.to raise_error Ably::Exceptions::MessageQueueingDisabled + it 'fails the deferrable' do expect(client.connection).to be_initialized - stop_reactor + channel.publish('event').errback do |error| + expect(error).to be_a(Ably::Exceptions::MessageQueueingDisabled) + stop_reactor + end end end context 'and connection state connecting' do - it 'raises an exception' do + it 'fails the deferrable' do client.connect EventMachine.next_tick do - expect { channel.publish('event') }.to raise_error Ably::Exceptions::MessageQueueingDisabled expect(client.connection).to be_connecting - stop_reactor + channel.publish('event').errback do |error| + expect(error).to be_a(Ably::Exceptions::MessageQueueingDisabled) + stop_reactor + end end end end context 'and connection state disconnected' do - let(:client_options) { default_options.merge(queue_messages: false, :log_level => :error ) } - it 'raises an exception' do + let(:client_options) { default_options.merge(queue_messages: false) } + it 'fails the deferrable' do client.connection.once(:connected) do client.connection.once(:disconnected) do - expect { channel.publish('event') }.to raise_error Ably::Exceptions::MessageQueueingDisabled expect(client.connection).to be_disconnected - stop_reactor + channel.publish('event').errback do |error| + expect(error).to be_a(Ably::Exceptions::MessageQueueingDisabled) + stop_reactor + end end client.connection.transition_state_machine :disconnected end end end @@ -445,10 +840,19 @@ stop_reactor end end end end + + context 'and additional invalid attributes' do + let(:client_id) { 1 } + + it 'throws an exception' do + expect { channel.publish([name: 'event', client_id: 1]) }.to raise_error ArgumentError, /client_id must be a String/ + stop_reactor + end + end end context 'with an array of Hash objects with :name and :data attributes' do let(:messages) do 10.times.map do |index| @@ -694,10 +1098,17 @@ expect { channel.publish([name: 'event', client_id: '*']) }.to raise_error Ably::Exceptions::IncompatibleClientId stop_reactor end end + context 'with a non-String client_id in the message' do + it 'throws an exception' do + expect { channel.publish([name: 'event', client_id: 1]) }.to raise_error ArgumentError, /client_id must be a String/ + stop_reactor + end + end + context 'with an empty client_id in the message' do it 'succeeds and publishes without a client_id' do channel.publish([name: 'event', client_id: nil]).tap do |deferrable| deferrable.errback { raise 'Should have succeeded' } end @@ -911,11 +1322,13 @@ context 'with a callback that raises an exception' do let(:exception) { StandardError.new("Intentional error") } it 'logs the error and continues' do emitted_exception = false - expect(client.logger).to receive(:error).with(/#{exception.message}/) + expect(client.logger).to receive(:error) do |*args, &block| + expect(args.concat([block ? block.call : nil]).join(',')).to match(/#{exception.message}/) + end channel.subscribe('click') do |message| emitted_exception = true raise exception end channel.publish('click', 'data') do @@ -984,52 +1397,57 @@ def fake_error(error) client.connection.manager.error_received_from_server error end - context 'an :attached channel' do - it 'transitions state to :failed' do - channel.attach do - channel.on(:failed) do |connection_state_change| - error = connection_state_change.reason - expect(error).to be_a(Ably::Exceptions::ConnectionFailed) - expect(error.code).to eql(80002) - stop_reactor + context 'an :attaching channel' do + it 'transitions state to :failed (#RTL3a)' do + connection.once(:connected) do + channel.once(:attaching) do + channel.on(:failed) do |connection_state_change| + error = connection_state_change.reason + expect(error).to be_a(Ably::Exceptions::ConnectionFailed) + expect(error.code).to eql(50000) + stop_reactor + end + fake_error connection_error end - fake_error connection_error + channel.attach end end + end - it 'emits an error event on the channel' do + context 'an :attached channel' do + it 'transitions state to :failed (#RTL3a)' do channel.attach do - channel.on(:error) do |error| + channel.on(:failed) do |connection_state_change| + error = connection_state_change.reason expect(error).to be_a(Ably::Exceptions::ConnectionFailed) - expect(error.code).to eql(80002) + expect(error.code).to eql(50000) stop_reactor end fake_error connection_error end end - it 'updates the channel error_reason' do + it 'updates the channel error_reason (#RTL3a)' do channel.attach do channel.on(:failed) do |connection_state_change| error = connection_state_change.reason expect(error).to be_a(Ably::Exceptions::ConnectionFailed) - expect(error.code).to eql(80002) + expect(error.code).to eql(50000) stop_reactor end fake_error connection_error end end end context 'a :detached channel' do - it 'remains in the :detached state' do + it 'remains in the :detached state (#RTL3a)' do channel.attach do channel.on(:failed) { raise 'Failed state should not have been reached' } - channel.on(:error) { raise 'Error should not have been emitted' } channel.detach do EventMachine.add_timer(1) do expect(channel).to be_detached stop_reactor @@ -1042,15 +1460,14 @@ end context 'a :failed channel' do let(:original_error) { RuntimeError.new } - it 'remains in the :failed state and ignores the failure error' do + it 'remains in the :failed state and ignores the failure error (#RTL3a)' do channel.attach do - channel.on(:error) do + channel.on(:failed) do channel.on(:failed) { raise 'Failed state should not have been reached' } - channel.on(:error) { raise 'Error should not have been emitted' } EventMachine.add_timer(1) do expect(channel).to be_failed expect(channel.error_reason).to eql(original_error) stop_reactor @@ -1063,40 +1480,56 @@ end end end context 'a channel ATTACH request' do - it 'raises an exception' do + it 'fails the deferrable (#RTL4b)' do client.connect do client.connection.once(:failed) do - expect { channel.attach }.to raise_error Ably::Exceptions::InvalidStateChange - stop_reactor + channel.attach.errback do |error| + expect(error).to be_a(Ably::Exceptions::InvalidStateChange) + stop_reactor + end end fake_error connection_error end end end end context ':closed' do context 'an :attached channel' do - it 'transitions state to :detached' do + it 'transitions state to :detached (#RTL3b)' do channel.attach do channel.on(:detached) do stop_reactor end client.connection.close end end end + context 'an :attaching channel (#RTL3b)' do + it 'transitions state to :detached' do + channel.on(:attaching) do + channel.on(:detached) do + stop_reactor + end + client.connection.__incoming_protocol_msgbus__.unsubscribe + client.connection.close + closed_message = Ably::Models::ProtocolMessage.new(action: 8) # CLOSED + client.connection.__incoming_protocol_msgbus__.publish :protocol_message, closed_message + end + channel.attach + end + end + context 'a :detached channel' do - it 'remains in the :detached state' do + it 'remains in the :detached state (#RTL3b)' do channel.attach do channel.detach do channel.on(:detached) { raise 'Detached state should not have been reached' } - channel.on(:error) { raise 'Error should not have been emitted' } EventMachine.add_timer(1) do expect(channel).to be_detached stop_reactor end @@ -1109,15 +1542,14 @@ context 'a :failed channel' do let(:client_options) { default_options.merge(log_level: :fatal) } let(:original_error) { Ably::Models::ErrorInfo.new(message: 'Error') } - it 'remains in the :failed state and retains the error_reason' do + it 'remains in the :failed state and retains the error_reason (#RTL3b)' do channel.attach do - channel.once(:error) do + channel.once(:failed) do channel.on(:detached) { raise 'Detached state should not have been reached' } - channel.on(:error) { raise 'Error should not have been emitted' } EventMachine.add_timer(1) do expect(channel).to be_failed expect(channel.error_reason).to eql(original_error) stop_reactor @@ -1130,54 +1562,81 @@ end end end context 'a channel ATTACH request when connection CLOSED' do - it 'raises an exception' do + it 'fails the deferrable (#RTL4b)' do client.connect do client.connection.once(:closed) do - expect { channel.attach }.to raise_error Ably::Exceptions::InvalidStateChange - stop_reactor + channel.attach.errback do |error| + expect(error).to be_a(Ably::Exceptions::InvalidStateChange) + stop_reactor + end end client.close end end end context 'a channel ATTACH request when connection CLOSING' do - it 'raises an exception' do + it 'fails the deferrable (#RTL4b)' do client.connect do client.connection.once(:closing) do - expect { channel.attach }.to raise_error Ably::Exceptions::InvalidStateChange - stop_reactor + channel.attach.errback do |error| + expect(error).to be_a(Ably::Exceptions::InvalidStateChange) + stop_reactor + end end client.close end end end end context ':suspended' do - context 'an :attached channel' do - let(:client_options) { default_options.merge(log_level: :fatal) } + context 'an :attaching channel' do + it 'transitions state to :suspended (#RTL3c)' do + channel.on(:attaching) do + channel.on(:suspended) do + stop_reactor + end + client.connection.once_or_if(:connecting) do + client.connection.transition_state_machine :suspended + end + end + channel.attach + end + end - it 'transitions state to :detached' do + context 'an :attached channel' do + it 'transitions state to :suspended (#RTL3c)' do channel.attach do - channel.on(:detached) do + channel.on(:suspended) do stop_reactor end client.connection.transition_state_machine :suspended end end + + it 'transitions state automatically to :attaching once the connection is re-established (#RTN15c3)' do + channel.attach do + channel.on(:suspended) do + client.connection.connect + channel.once(:attached) do + stop_reactor + end + end + client.connection.transition_state_machine :suspended + end + end end context 'a :detached channel' do - it 'remains in the :detached state' do + it 'remains in the :detached state (#RTL3c)' do channel.attach do channel.detach do channel.on(:detached) { raise 'Detached state should not have been reached' } - channel.on(:error) { raise 'Error should not have been emitted' } EventMachine.add_timer(1) do expect(channel).to be_detached stop_reactor end @@ -1190,15 +1649,14 @@ context 'a :failed channel' do let(:original_error) { RuntimeError.new } let(:client_options) { default_options.merge(log_level: :fatal) } - it 'remains in the :failed state and retains the error_reason' do + it 'remains in the :failed state and retains the error_reason (#RTL3c)' do channel.attach do - channel.once(:error) do + channel.once(:failed) do channel.on(:detached) { raise 'Detached state should not have been reached' } - channel.on(:error) { raise 'Error should not have been emitted' } EventMachine.add_timer(1) do expect(channel).to be_failed expect(channel.error_reason).to eql(original_error) stop_reactor @@ -1210,24 +1668,146 @@ channel.transition_state_machine :failed, reason: original_error end end end - context 'a channel ATTACH request when connection SUSPENDED' do + context 'a channel ATTACH request when connection SUSPENDED (#RTL4b)' do let(:client_options) { default_options.merge(log_level: :fatal) } - it 'raises an exception' do + it 'fails the deferrable' do client.connect do client.connection.once(:suspended) do - expect { channel.attach }.to raise_error Ably::Exceptions::InvalidStateChange - stop_reactor + channel.attach.errback do |error| + expect(error).to be_a(Ably::Exceptions::InvalidStateChange) + stop_reactor + end end client.connection.transition_state_machine :suspended end end end end + + context ':connected' do + context 'a :suspended channel' do + it 'is automatically reattached (#RTL3d)' do + channel.attach do + channel.once(:suspended) do + client.connection.connect + channel.once(:attached) do + stop_reactor + end + end + client.connection.transition_state_machine :suspended + end + end + + context 'when re-attach attempt fails' do + let(:client_options) do + default_options.merge(realtime_request_timeout: 2, log_level: :fatal) + end + + it 'returns to a suspended state (#RTL3d)' do + channel.attach do + channel.once(:attached) do + fail "Channel should not have become attached" + end + + channel.once(:suspended) do + client.connection.connect + channel.once(:attaching) do + # don't process any incoming ProtocolMessages so the connection never opens + client.connection.__incoming_protocol_msgbus__.unsubscribe + channel.once(:suspended) do |state_change| + expect(state_change.reason.code).to eql(90007) + stop_reactor + end + end + end + client.connection.transition_state_machine :suspended + end + end + end + end + end + + context ':disconnected' do + context 'with an initialized channel' do + it 'has no effect on the channel states (#RTL3e)' do + connection.once(:connected) do + expect(channel).to be_initialized + connection.once(:disconnected) do + expect(channel).to be_initialized + stop_reactor + end + disconnect_transport + end + end + end + + context 'with an attaching channel' do + it 'has no effect on the channel states (#RTL3e)' do + connection.once(:connected) do + channel.once(:attaching) do + connection.once(:disconnected) do + expect(channel).to be_attaching + stop_reactor + end + disconnect_transport + end + channel.attach + end + end + end + + context 'with an attached channel' do + it 'has no effect on the channel states (#RTL3e)' do + channel.attach do + connection.once(:disconnected) do + expect(channel).to be_attached + stop_reactor + end + disconnect_transport + end + end + end + + context 'with a detached channel' do + it 'has no effect on the channel states (#RTL3e)' do + channel.attach do + channel.detach do + connection.once(:disconnected) do + expect(channel).to be_detached + stop_reactor + end + disconnect_transport + end + end + end + end + + context 'with a failed channel' do + let(:client_options) do + default_options.merge( + default_token_params: { capability: { "foo" =>["*"] } }, + use_token_auth: true, + log_level: :fatal + ) + end + + it 'has no effect on the channel states (#RTL3e)' do + channel.once(:failed) do + connection.once(:disconnected) do + expect(channel).to be_failed + stop_reactor + end + disconnect_transport + end + channel.attach + end + end + end end describe '#presence' do it 'returns a Ably::Realtime::Presence object' do expect(channel.presence).to be_a(Ably::Realtime::Presence) @@ -1245,24 +1825,35 @@ end context 'ChannelStateChange object' do it 'has current state' do channel.on(:attached) do |channel_state_change| + expect(channel_state_change.current).to be_a(Ably::Realtime::Channel::STATE) expect(channel_state_change.current).to eq(:attached) stop_reactor end channel.attach end it 'has a previous state' do channel.on(:attached) do |channel_state_change| + expect(channel_state_change.previous).to be_a(Ably::Realtime::Channel::STATE) expect(channel_state_change.previous).to eq(:attaching) stop_reactor end channel.attach end + it 'has the event that generated the state change (#TA5)' do + channel.on(:attached) do |channel_state_change| + expect(channel_state_change.event).to be_a(Ably::Realtime::Channel::EVENT) + expect(channel_state_change.event).to eq(:attached) + stop_reactor + end + channel.attach + end + it 'contains a private API protocol_message attribute that is used for special state change events', :api_private do channel.on(:attached) do |channel_state_change| expect(channel_state_change.protocol_message).to be_a(Ably::Models::ProtocolMessage) expect(channel_state_change.reason).to be_nil stop_reactor @@ -1291,9 +1882,269 @@ channel.attach do error = Ably::Exceptions::ConnectionFailed.new('forced failure', 500, 50000) client.connection.manager.error_received_from_server error end end + end + + context '#resume (#RTL2f)' do + it 'is false when a channel first attaches' do + channel.attach + channel.on(:attached) do |channel_state_change| + expect(channel_state_change.resumed).to be_falsey + stop_reactor + end + end + + it 'is true when a connection is recovered and the channel is attached' do + channel.attach + channel.once(:attached) do |channel_state_change| + connection_id = client.connection.id + expect(channel_state_change.resumed).to be_falsey + + recover_client = auto_close Ably::Realtime::Client.new(client_options.merge(recover: client.connection.recovery_key)) + recover_client.connection.once(:connected) do + expect(recover_client.connection.id).to eql(connection_id) + recover_channel = recover_client.channels.get(channel_name) + recover_channel.attach + recover_channel.once(:attached) do |recover_channel_state_change| + expect(recover_channel_state_change.resumed).to be_truthy + stop_reactor + end + end + end + end + + it 'is false when a connection fails to recover and the channel is attached' do + client.connection.once(:connected) do + recovery_key = client.connection.recovery_key + client.connection.once(:closed) do + recover_client = auto_close Ably::Realtime::Client.new(client_options.merge(recover: recovery_key, log_level: :error)) + recover_client.connection.once(:connected) do + recover_channel = recover_client.channels.get(channel_name) + recover_channel.attach + recover_channel.once(:attached) do |recover_channel_state_change| + expect(recover_channel_state_change.resumed).to be_falsey + stop_reactor + end + end + end + + client.close + end + end + + context 'when a resume fails' do + let(:client_options) { default_options.merge(log_level: :error) } + + it 'is false when a resume fails to recover and the channel is automatically re-attached' do + channel.attach do + connection_id = client.connection.id + channel.once(:attached) do |channel_state_change| + expect(client.connection.id).to_not eql(connection_id) + expect(channel_state_change.resumed).to be_falsey + stop_reactor + end + client.connection.transport.close_connection_after_writing + client.connection.configure_new '0123456789abcdef', 'wVIsgTHAB1UvXh7z-1991d8586', -1 # force the resume connection key to be invalid + end + end + end + end + end + + context 'moves to' do + %w(suspended detached failed).each do |channel_state| + context(channel_state) do + specify 'all queued messages fail with NACK (#RTL11)' do + channel.attach do + # Move to disconnected + disconnect_transport_proc = Proc.new do + if connection.transport + connection.transport.close_connection_after_writing + else + EventMachine.next_tick { disconnect_transport_proc.call } + end + end + disconnect_transport_proc.call + + connection.on(:connecting) { disconnect_transport_proc.call } + + connection.once(:disconnected) do + channel.publish("foo").errback do |error| + stop_reactor + end + channel.transition_state_machine channel_state.to_sym + end + end + end + + specify 'all published messages awaiting an ACK do nothing (#RTL11a)' do + connection_been_disconnected = false + + channel.attach do + deferrable = channel.publish("foo") + deferrable.errback do |error| + fail "Message publish should not fail" + end + deferrable.callback do |error| + EventMachine.add_timer(0.5) do + expect(connection_been_disconnected).to be_truthy + stop_reactor + end + end + + # Allow 5ms for message to be sent into the socket TCP/IP stack + EventMachine.add_timer(0.005) do + connection.transport.close_connection_after_writing + connection.once(:disconnected) do + connection_been_disconnected = true + channel.transition_state_machine channel_state.to_sym + end + end + end + end + end + end + end + end + + context 'when it receives a server-initiated DETACHED (#RTL13)' do + let(:detached_action) { 13 } + + context 'and channel is initialized (#RTL13)' do + it 'does nothing' do + connection.once(:connected) do + channel.on { raise 'Channel state should not change' } + + detach_message = Ably::Models::ProtocolMessage.new(action: detached_action, channel: channel_name) + client.connection.__incoming_protocol_msgbus__.publish :protocol_message, detach_message + + EventMachine.add_timer(1) { stop_reactor } + end + end + end + + context 'and channel is failed' do + let(:client_options) { + default_options.merge( + use_token_auth: true, + default_token_params: { capability: { "foo" => ["publish"] } }, + log_level: :fatal + ) + } + + it 'does nothing (#RTL13)' do + connection.once(:connected) do + channel.attach + channel.once(:failed) do + channel.on { raise 'Channel state should not change' } + + detach_message = Ably::Models::ProtocolMessage.new(action: detached_action, channel: channel_name) + client.connection.__incoming_protocol_msgbus__.publish :protocol_message, detach_message + + EventMachine.add_timer(1) { stop_reactor } + end + end + end + end + + context 'and channel is attached' do + it 'reattaches immediately (#RTL13a)' do + channel.attach do + channel.once(:attaching) do |state_change| + expect(state_change.reason.code).to eql(50505) + channel.once(:attached) do + stop_reactor + end + end + + detach_message = Ably::Models::ProtocolMessage.new(action: detached_action, channel: channel_name, error: { code: 50505 }) + client.connection.__incoming_protocol_msgbus__.publish :protocol_message, detach_message + end + end + end + + context 'and channel is suspended' do + it 'reattaches immediately (#RTL13a)' do + channel.attach do + channel.once(:suspended) do + channel.once(:attaching) do |state_change| + expect(state_change.reason.code).to eql(50505) + channel.once(:attached) do + stop_reactor + end + end + + detach_message = Ably::Models::ProtocolMessage.new(action: detached_action, channel: channel_name, error: { code: 50505 }) + client.connection.__incoming_protocol_msgbus__.publish :protocol_message, detach_message + end + + channel.transition_state_machine! :suspended + end + end + end + + context 'and channel is attaching' do + let(:client_options) { default_options.merge(channel_retry_timeout: 2, realtime_request_timeout: 1, log_level: :fatal) } + + it 'will move to the SUSPENDED state and then attempt to ATTACH with the ATTACHING state (#RTL13b)' do + connection.once(:connected) do + # Prevent any incoming or outgoing ATTACH/ATTACHED message from Ably + prevent_protocol_messages_proc = Proc.new do + if client.connection.transport + client.connection.transport.__incoming_protocol_msgbus__.unsubscribe + client.connection.transport.__outgoing_protocol_msgbus__.unsubscribe + else + EventMachine.next_tick { prevent_protocol_messages_proc.call } + end + end + prevent_protocol_messages_proc.call + end + + channel.once(:attaching) do + attaching_at = Time.now + # First attaching fails during server-initiated ATTACHED received + channel.once(:suspended) do |state_change| + expect(Time.now.to_i - attaching_at.to_i).to be_within(1).of(1) + + suspended_at = Time.now + # Automatic attach happens at channel_retry_timeout + channel.once(:attaching) do + expect(Time.now.to_i - attaching_at.to_i).to be_within(1).of(2) + channel.once(:suspended) do + channel.once(:attaching) do + channel.once(:attached) do + stop_reactor + end + # Simulate ATTACHED from Ably + attached_message = Ably::Models::ProtocolMessage.new(action: 11, channel: channel_name) # ATTACHED + client.connection.__incoming_protocol_msgbus__.publish :protocol_message, attached_message + end + end + end + end + + detach_message = Ably::Models::ProtocolMessage.new(action: detached_action, channel: channel_name) + client.connection.__incoming_protocol_msgbus__.publish :protocol_message, detach_message + end + channel.attach + end + end + end + + context 'when it receives an ERROR ProtocolMessage' do + let(:client_options) { default_options.merge(log_level: :fatal) } + + it 'should transition to the failed state and the error_reason should be set (#RTL14)' do + channel.attach do + channel.once(:failed) do |state_change| + expect(state_change.reason.code).to eql(50505) + expect(channel.error_reason.code).to eql(50505) + stop_reactor + end + error_message = Ably::Models::ProtocolMessage.new(action: 9, channel: channel_name, error: { code: 50505 }) # ProtocolMessage ERROR type + client.connection.__incoming_protocol_msgbus__.publish :protocol_message, error_message end end end end end