如何测试 Hanami 的 WebSockets?

How to test WebSockets For Hanami?

使用以下内容:

我已经能够将 WebSockets 添加到 Hanami,但是由于这是用于生产代码,我想添加规范;但我找不到有关如何使用 Rspec.

测试 WebSockets 和 Hanami 的信息

我已经为 找到了这个,但没有找到非 Rails 特定的或 Hanami 特定的,我已经在 Hanami Gitter 上询问过但还没有得到回复。

就是TCR gem the only way? I would prefer something simpler but If I must how would I set it up for anycable-go via litecable.

如何使用 Rspec 测试 Hanami 的 WebSockets?

要完成这项工作需要几个活动部分,第一个是模拟网络服务器上接收套接字的套接字模拟器:

注意url_path 应该自定义为适用于您的网络套接字特定端点的内容

# frozen_string_literal: true

require 'puma'
require 'lite_cable/server'
require_relative 'sync_client'

class SocketSimulator
  def initialize(x_site_id_header: nil)
    @server_logs = []
    @x_site_id_header = x_site_id_header
  end

  attr_accessor :server_logs

  def client
    return @client if @client

    url_path = "/ws?connection_token=#{connection_token}"

    @client = SyncClient.new("ws://127.0.0.1:3099#{url_path}", headers: headers, cookies: '')
  end

  def connection_token
    @connection_token ||= SecureRandom.hex
  end

  def user
    return @user if @user

    email = "#{SecureRandom.hex}@mailinator.com"
    password = SecureRandom.hex

    @user = Fabricate.create :user, email: email, site_id: site_id, password: password
  end

  def start
    @server = Puma::Server.new(
      LiteCable::Server::Middleware.new(nil, connection_class: Api::Sockets::Connection),
      Puma::Events.strings
    ).tap do |server|
      server.add_tcp_listener '127.0.0.1', 3099
      server.min_threads = 1
      server.max_threads = 4
    end

    @server_thread = Thread.new { @server.run.join }
  end

  def teardown
    @server&.stop(true)
    @server_thread&.join
    @server_logs.clear
  end

  def headers
    {
      'AUTHORIZATION' => "Bearer #{jwt}",
      'X_HANAMI_DIRECT_BOOKINGS_SITE_ID' => @x_site_id_header || site_id
    }
  end

  def site_id
    @site_id ||= SecureRandom.hex
  end

  def jwt
    @jwt ||= Interactors::Users::GenerateJwt.new(user, site_id).call.jwt
  end
end

接下来是 SyncClient,它是一个假客户端,您可以使用它来实际连接到模拟套接字:

# frozen_string_literal: true

# Synchronous websocket client
# Copied and modified from https://github.com/palkan/litecable/blob/master/spec/support/sync_client.rb
class SyncClient
  require 'websocket-client-simple'
  require 'concurrent'
  require 'socket'

  WAIT_WHEN_EXPECTING_EVENT = 5
  WAIT_WHEN_NOT_EXPECTING_EVENT = 0.5

  attr_reader :pings

  def initialize(url, headers: {}, cookies: '')
    @messages = Queue.new
    @closed = Concurrent::Event.new
    @has_messages = Concurrent::Semaphore.new(0)
    @pings = Concurrent::AtomicFixnum.new(0)

    @open = Concurrent::Promise.new

    @ws = set_up_web_socket(url, headers.merge('COOKIE' => cookies))

    @open.wait!(WAIT_WHEN_EXPECTING_EVENT)
  end

  def ip
    Socket.ip_address_list.detect(&:ipv4_private?).try(:ip_address)
  end

  def set_up_web_socket(url, headers)
    WebSocket::Client::Simple.connect(
      url,
      headers: headers
    ) do |ws|
      ws.on(:error, &method(:on_error))

      ws.on(:open, &method(:on_open))

      ws.on(:message, &method(:on_message))

      ws.on(:close, &method(:on_close))
    end
  end

  def on_error(event)
    event = RuntimeError.new(event.message) unless event.is_a?(Exception)

    if @open.pending?
      @open.fail(event)
    else
      @messages << event
      @has_messages.release
    end
  end

  def on_open(_event = nil)
    @open.set(true)
  end

  def on_message(event)
    if event.type == :close
      @closed.set
    else
      message = JSON.parse(event.data)
      if message['type'] == 'ping'
        @pings.increment
      else
        @messages << message
        @has_messages.release
      end
    end
  end

  def on_close(_event = nil)
    @closed.set
  end

  def read_message
    @has_messages.try_acquire(1, WAIT_WHEN_EXPECTING_EVENT)

    msg = @messages.pop(true)
    raise msg if msg.is_a?(Exception)

    msg
  end

  def read_messages(expected_size = 0)
    list = []
    loop do
      list_is_smaller = list.size < expected_size ? WAIT_WHEN_EXPECTING_EVENT : WAIT_WHEN_NOT_EXPECTING_EVENT
      break unless @has_messages.try_acquire(1, list_is_smaller)

      msg = @messages.pop(true)
      raise msg if msg.is_a?(Exception)

      list << msg
    end
    list
  end

  def send_message(message)
    @ws.send(JSON.generate(message))
  end

  def close
    sleep WAIT_WHEN_NOT_EXPECTING_EVENT

    raise "#{@messages.size} messages unprocessed" unless @messages.empty?

    @ws.close
    wait_for_close
  end

  def wait_for_close
    @closed.wait(WAIT_WHEN_EXPECTING_EVENT)
  end

  def closed?
    @closed.set?
  end
end

最后一部分是要测试的假频道:

# frozen_string_literal: true

class FakeChannel < Api::Sockets::ApplicationChannel
  identifier :fake

  def subscribed
    logger.info "Can Reject? #{can_reject?}"
    reject if can_reject?

    logger.debug "Streaming from #{stream_location}"
    stream_from stream_location
  end

  def unsubscribed
    transmit message: 'Goodbye channel!'
  end

  def can_reject?
    logger.info "PARAMS: #{params}"
    params.fetch('value_to_check', 0) > 5
  end

  def foo
    transmit('bar')
  end
end

在规范中使用:

# frozen_string_literal: true

require_relative '../../../websockets-test-utils/fake_channel'
require_relative '../../../websockets-test-utils/socket_simulator'

RSpec.describe Interactors::Channels::Broadcast, db_truncation: true do
  subject(:interactor) { described_class.new(token: connection_token, loc: 'fake', message: message) }

  let(:identifier) { { channel: 'fake' }.to_json }

  let(:socket_simulator) { SocketSimulator.new }
  let(:client) { socket_simulator.client }
  let(:user) { socket_simulator.user }
  let(:connection_token) { socket_simulator.connection_token }
  let(:channel) { 'fake' }
  let(:message) { 'woooooo' }

  before do
    socket_simulator.start
  end

  after do
    socket_simulator.teardown
  end

  describe 'call' do
    before do
      client.send_message command: 'subscribe',
                          identifier: identifier
    end

    it 'broadcasts a message to the correct channel' do
      expect(client.read_message).to eq('type' => 'welcome')
      expect(client.read_message).to eq(
        'identifier' => identifier,
        'type' => 'confirm_subscription'
      )
      interactor.call
      expect(client.read_message).to eq(
        'identifier' => identifier,
        'message' => message
      )
    end

    context 'with other connection' do
      let(:user2) { Fabricate.create :user }
      let(:jwt) { Interactors::Users::GenerateJwt.new(user2, site_id).call.jwt }
      let(:site_id) { socket_simulator.site_id }
      let(:url_path) { "/ws?connection_token=#{SecureRandom.hex}" }
      let(:client2) { SyncClient.new("ws://127.0.0.1:3099#{url_path}", headers: {}, cookies: '') }

      before do
        client2.send_message command: 'subscribe',
                             identifier: identifier
      end

      it "doesn't broadcast to connections that shouldn't get it" do
        aggregate_failures 'broadcast!' do
          expect(client2.read_message).to eq('type' => 'welcome')
          expect(client2.read_message).to eq(
            'identifier' => identifier,
            'type' => 'confirm_subscription'
          )
          expect(client.read_message).to eq('type' => 'welcome')
          expect(client.read_message).to eq(
            'identifier' => identifier,
            'type' => 'confirm_subscription'
          )
          interactor.call
          sleep 1

          expect(client.read_message).to eq(
            'identifier' => identifier,
            'message' => message
          )
          expect { client2.close }.not_to raise_exception
        end
      end
    end
  end
end