|
# 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 |