# frozen_string_literal: true require "rails_helper" # Async testing contexts require "async/rspec" require "async/websocket/adapters/http" RSpec.describe Slack::SocketMode::Client do include_context Async::RSpec::Reactor let(:client) { described_class.new(url:) } let(:handler) { Slack::SocketMode::Handler } # Port 999 is currently unused in the test environment, but may need to be # changed in the future or for CI. let(:url) { "http://0.0.0.0:999/?asdf=123" } let(:protocol) { Async::HTTP::Protocol::HTTP1 } let(:endpoint) { Async::HTTP::Endpoint.parse(url, timeout: 0.8, reuse_port: true, protocol:) } let(:hello_message) { { type: "hello" } } let(:events_api_message) { { type: "events_api", envelope_id: generate(:envelope_id) } } let(:interactive_message) { { type: "interactive", envelope_id: generate(:envelope_id) } } let(:no_envelope_id_message) { { type: "events_api" } } let(:invalid_message) { "not json" } # For some reason we can send 3 messages fine, but anything else sent beyond # that isn't received by the client. # See https://github.com/socketry/async-websocket/discussions/71#discussioncomment-10708059 let(:payload_proc) do ->(connection) do connection.send_text(hello_message.to_json) connection.send_text(events_api_message.to_json) connection.send_text(interactive_message.to_json) end end let(:app) do Protocol::HTTP::Middleware.for do |request| Async::WebSocket::Adapters::HTTP.open(request) do |connection| payload_proc.call(connection) rescue Protocol::WebSocket::ClosedError # Ignore this error. ensure connection.close end or Protocol::HTTP::Response[404, {}, []] end end before do # Suppress WebMock's auto-hijacking of Async::HTTP::Clients # Uncomment next line if using Webmock # WebMock::HttpLibAdapters::AsyncHttpClientAdapter.disable! # Bind the endpoint before running the server so that we know incoming # connections will be accepted @bound_endpoint = endpoint.bound # Make the bound endpoint quack like a regular endpoint for the server # which expects an unbound endpoint. @bound_endpoint.extend(SingleForwardable) @bound_endpoint.def_delegators(:endpoint, :authority, :path, :protocol, :scheme) # Bind an async server to the bound endpoint using our async websocket app @server = Async::HTTP::Server.new(app, @bound_endpoint) @server_task = Async do @server.run end # As with the server endpoint, we need a bound client endpoint that quacks # like an regular endpoint for the sake of the client. @client_endpoint = @bound_endpoint.local_address_endpoint(timeout: endpoint.timeout) @client_endpoint.instance_variable_set(:@endpoint, endpoint) @client_endpoint.extend(SingleForwardable) @client_endpoint.def_delegators(:@endpoint, :authority, :path, :protocol, :scheme) # Configure the websocket client to use our bound client endpoint allow(Async::WebSocket::Client).to receive(:connect).and_wrap_original do |original_method, *arguments, &block| original_method.call(@client_endpoint, *arguments[1..], &block) end end after do # Use a timeout that is slightly longer than the endpoint timeout to avoid # hanging when closing the client. Async::Task.current.with_timeout(1) do @server_task&.stop end rescue RuntimeError # Ignore the error that is raised if there is no current async task running nil ensure # Close our websocket client connection client.close # Close the bound endpoint to free up the address for the next test @bound_endpoint&.close # Re-enable WebMock's auto-hijacking of Async::HTTP::Clients # Uncomment next line if using Webmock # WebMock::HttpLibAdapters::AsyncHttpClientAdapter.enable! end describe "#listen" do it "reads from the connection until closed" do messages = [] client.listen do |payload| messages << payload end expect(messages).to contain_exactly(events_api_message, interactive_message) end it "sends an acknowledgement for each message" do acknowledgements = [] expect(Protocol::WebSocket::TextMessage).to receive(:generate).twice.and_wrap_original do |original_method, *arguments, &block| acknowledgements << arguments.first acknowledgement = original_method.call(*arguments, &block) expect(client.connection).to be_a(Async::WebSocket::Connection) expect(acknowledgement).to receive(:send).with(client.connection).and_call_original acknowledgement end client.listen {} expect(acknowledgements.map(&:to_h)).to contain_exactly( { envelope_id: events_api_message[:envelope_id] }, { envelope_id: interactive_message[:envelope_id] } ) end context "when the payload does not include an envelope ID" do let(:payload_proc) do ->(connection) do connection.send_text(hello_message.to_json) connection.send_text(no_envelope_id_message.to_json) end end it "reports an error to failbot" do client.listen {} expect(Failbot.reports.count).to eq(1) report = Failbot.reports.first expect(report["exception_detail"].count).to eq(1) expect(report["exception_detail"].first).to include( "type" => described_class::InvalidPayloadFormatError.name, "value" => "Missing envelope ID" ) expect(report).to include("payload" => no_envelope_id_message.inspect) end end context "when the first message is not a hello" do let(:payload_proc) do ->(connection) do connection.send_text(events_api_message.to_json) end end it "raises an error" do expect { client.listen {} }.to raise_error(described_class::ConnectionError) end end end describe "#endpoint_url" do it "returns the URL" do expect(client.endpoint_url).to eq url end context "when the debug option is set" do it "adds debug info to the URL" do client = described_class.new(url:, debug: true) expect(client.endpoint_url).to eq "#{url}&debug_reconnects=true" end end end describe "#parse_payload" do context "when the payload is a Protocol::WebSocket::Message" do it "parses the Protocol::WebSocket::Message" do message = Protocol::WebSocket::Message.new(hello_message.to_json) parsed_payload = client.parse_payload(message) expect(parsed_payload).to eq hello_message end end context "when the payload is a Hash" do it "passes the Hash through" do parsed_payload = client.parse_payload(hello_message) expect(parsed_payload).to eq hello_message end it "symbolizes the keys" do parsed_payload = client.parse_payload(hello_message.stringify_keys) expect(hello_message.keys).to all(be_a(Symbol)) expect(parsed_payload).to eq hello_message end end context "when the payload is neither a Message nor a Hash" do it "reports an error to failbot" do client.parse_payload(invalid_message) expect(Failbot.reports.count).to eq(1) report = Failbot.reports.first expect(report["exception_detail"].count).to eq(1) expect(report["exception_detail"].first).to include( "type" => described_class::InvalidPayloadFormatError.name, "value" => "Unrecognized payload format" ) expect(report).to include("payload" => invalid_message.inspect) end end end end