diff --git a/lib/rage/rspec.rb b/lib/rage/rspec.rb new file mode 100644 index 00000000..8d0656a6 --- /dev/null +++ b/lib/rage/rspec.rb @@ -0,0 +1,177 @@ +# frozen_string_literal: true + +require "rack/test" +require "json" + +# set up environment +ENV["RAGE_ENV"] ||= ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "test" + +# load the app +require "bundler/setup" +require "rage" +require_relative "#{Rage.root}/config/application" + +# verify the environment +abort("The test suite is running in #{Rage.env} mode instead of 'test'!") unless Rage.env.test? + +# mock fiber methods as RSpec tests don't run concurrently +class Fiber + def self.schedule(&block) + fiber = Fiber.new(blocking: true) do + Fiber.current.__set_result(block.call) + end + fiber.resume + + fiber + end + + def self.await(_) + # no-op + end +end + +# define request helpers +module RageRequestHelpers + include Rack::Test::Methods + + alias_method :response, :last_response + + APP = Rack::Builder.parse_file("#{Rage.root}/config.ru").yield_self do |app| + app.is_a?(Array) ? app[0] : app + end + + def app + APP + end + + %w(get options head).each do |method_name| + class_eval <<~RUBY, __FILE__, __LINE__ + 1 + def #{method_name}(path, params: {}, headers: {}) + request("#{method_name.upcase}", path, params: params, headers: headers) + end + RUBY + end + + %w(post put patch delete).each do |method_name| + class_eval <<~RUBY, __FILE__, __LINE__ + 1 + def #{method_name}(path, params: {}, headers: {}, as: nil) + if as == :json + params = params.to_json + headers["content-type"] = "application/json" + end + + request("#{method_name.upcase}", path, params: params, headers: headers.merge("IODINE_HAS_BODY" => !params.empty?)) + end + RUBY + end + + def request(method, path, params: {}, headers: {}) + if headers.any? + headers = headers.transform_keys do |k| + if k.downcase == "content-type" + "CONTENT_TYPE" + elsif k.downcase == "content-length" + "CONTENT_LENGTH" + elsif k.upcase == k + k + else + "HTTP_#{k.tr("-", "_").upcase! || k}" + end + end + end + + custom_request(method, path, params, headers) + end + + def host!(host) + @__host = host + end + + def default_host + @__host || "example.org" + end +end + +# include request helpers +RSpec.configure do |config| + config.include(RageRequestHelpers, type: :request) +end + +# patch MockResponse class +class Rack::MockResponse + def parsed_body + if headers["content-type"].start_with?("application/json") + JSON.parse(body) + else + body + end + end + + def code + status.to_s + end + + alias_method :response_code, :status +end + +# define http status matcher +RSpec::Matchers.matcher :have_http_status do |expected| + codes = Rack::Utils::SYMBOL_TO_STATUS_CODE + + failure_message do |response| + actual = response.status + + if expected.is_a?(Integer) + "expected the response to have status code #{expected} but it was #{actual}" + elsif expected == :success + "expected the response to have a success status code (2xx) but it was #{actual}" + elsif expected == :error + "expected the response to have an error status code (5xx) but it was #{actual}" + elsif expected == :missing + "expected the response to have a missing status code (404) but it was #{actual}" + else + "expected the response to have status code :#{expected} (#{codes[expected]}) but it was :#{codes.key(actual)} (#{actual})" + end + end + + failure_message_when_negated do |response| + actual = response.status + + if expected.is_a?(Integer) + "expected the response not to have status code #{expected} but it was #{actual}" + elsif expected == :success + "expected the response not to have a success status code (2xx) but it was #{actual}" + elsif expected == :error + "expected the response not to have an error status code (5xx) but it was #{actual}" + elsif expected == :missing + "expected the response not to have a missing status code (404) but it was #{actual}" + else + "expected the response not to have status code :#{expected} (#{codes[expected]}) but it was :#{codes.key(actual)} (#{actual})" + end + end + + match do |response| + actual = response.status + + case expected + when :success + actual >= 200 && actual < 300 + when :error + actual >= 500 + when :missing + actual == 404 + when Symbol + actual == codes.fetch(expected) + else + actual == expected + end + end +end + +if defined? RSpec::Rails::Matchers + module RSpec::Rails::Matchers + def have_http_status(_) + super + end + end +end diff --git a/rage.gemspec b/rage.gemspec index a368df05..794ca2ef 100644 --- a/rage.gemspec +++ b/rage.gemspec @@ -31,4 +31,5 @@ Gem::Specification.new do |spec| spec.add_dependency "rack", "~> 2.0" spec.add_dependency "rage-iodine", "~> 3.0" spec.add_dependency "zeitwerk", "~> 2.6" + spec.add_dependency "rack-test", "~> 2.1" end diff --git a/spec/rspec/config.ru b/spec/rspec/config.ru new file mode 100644 index 00000000..f8774ed1 --- /dev/null +++ b/spec/rspec/config.ru @@ -0,0 +1,2 @@ +run Rage.application +Rage.load_middlewares(self) diff --git a/spec/rspec/config/application.rb b/spec/rspec/config/application.rb new file mode 100644 index 00000000..5daddcbc --- /dev/null +++ b/spec/rspec/config/application.rb @@ -0,0 +1 @@ +Rage.configure {} diff --git a/spec/rspec/rspec_spec.rb b/spec/rspec/rspec_spec.rb new file mode 100644 index 00000000..506d53a4 --- /dev/null +++ b/spec/rspec/rspec_spec.rb @@ -0,0 +1,134 @@ +module RspecHelpersSpec + class TestController < RageController::API + def index + render json: %w(test_1 test_2 test_3) + end + + def params_action + render plain: params[:i] + end + + def headers_action + if request.headers["Content-Type"] == "text/plain" && + request.headers["Cache-Control"] == "max-age=604800, must-revalidate" && + request.headers["Last-Modified"] == "Sun, 03 Sep 2017 00:17:02 GMT" + head :ok + else + head :bad_request + end + end + + def fibers_action + i = 0 + + Fiber.await([ + Fiber.schedule { i += 1 }, + Fiber.schedule { i += 2 }, + ]) + + render plain: i + end + + def subdomain + head :ok + end + end + + Rage.routes.draw do + root to: "rspec_helpers_spec/test#index" + + get "params", to: "rspec_helpers_spec/test#params_action" + post "params", to: "rspec_helpers_spec/test#params_action" + + get "headers", to: "rspec_helpers_spec/test#headers_action" + get "fibers", to: "rspec_helpers_spec/test#fibers_action" + + get "subdomain", to: "rspec_helpers_spec/test#subdomain", constraints: { host: /rage-test/ } + end +end + +RSpec.describe "RSpec helpers", type: :request do + before do + allow(Rage).to receive(:root).and_return(Pathname.new(__dir__).expand_path) + require "rage/rspec" + end + + it "correctly parses responses" do + get "/" + + expect(response.body).to eq('["test_1","test_2","test_3"]') + expect(response.parsed_body).to eq(%w(test_1 test_2 test_3)) + end + + it "correctly matches http statuses" do + get "/" + + expect(response).to have_http_status(:ok) + expect(response).to have_http_status(200) + + expect { + expect(response).to have_http_status(:missing) + }.to raise_error("expected the response to have a missing status code (404) but it was 200") + + expect { + expect(response).to have_http_status(500) + }.to raise_error("expected the response to have status code 500 but it was 200") + + expect { + expect(response).not_to have_http_status(:success) + }.to raise_error("expected the response not to have a success status code (2xx) but it was 200") + end + + it "correctly passes params" do + get "/params?i=222" + expect(response.body).to eq("222") + + post "/params?i=333" + expect(response.body).to eq("333") + + post "/params", params: { i: "444" } + expect(response.body).to eq("444") + + post "/params", params: { i: "555" }, as: :json + expect(response.body).to eq("555") + end + + it "works correctly with no params" do + post "/params" + expect(response.body).to eq("") + end + + it "correctly passes headers" do + get "/headers", headers: { + "content-type" => "text/plain", + "Cache-Control" => "max-age=604800, must-revalidate", + "HTTP_LAST_MODIFIED" => "Sun, 03 Sep 2017 00:17:02 GMT" + } + + expect(response).to have_http_status(:ok) + end + + it "allows to correctly schedule fibers" do + get "/fibers" + expect(response.body).to eq("3") + end + + it "uses the default host value" do + get "/subdomain" + expect(response).to have_http_status(:not_found) + end + + it "allows to modify host value" do + host! "rage-test.com" + get "/subdomain" + + expect(response).to have_http_status(:ok) + end + + it "allows to stub app calls" do + allow_any_instance_of(RageController::API).to receive(:params).and_return({ i: "12345" }) + + get "/params?i=111" + expect(response.body).to eq("12345") + end +end