diff --git a/README.md b/README.md index 2a969e8..81d3ef8 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,6 @@ [![License](https://img.shields.io/hexpm/l/bypass.svg)](https://github.com/PSPDFKit-labs/bypass/blob/master/LICENSE) [![Last Updated](https://img.shields.io/github/last-commit/PSPDFKit-labs/bypass.svg)](https://github.com/PSPDFKit-labs/bypass/commits/master) - `Bypass` provides a quick way to create a custom plug that can be put in place instead of an actual HTTP server to return prebaked responses to client requests. This is most useful in tests, when you want to create a mock HTTP @@ -31,10 +30,14 @@ the same port again. Both functions block until the socket updates its state. You can take any of the following approaches: -* `expect/2` or `expect_once/2` to install a generic function that all calls to +* `expect/2`, `expect/3` or `expect_once/2` to install a generic function that all calls to bypass will use -* `expect/4` and/or `expect_once/4` to install specific routes (method and path) +* `expect/4`, `expect/5` and/or `expect_once/4` to install specific routes (method and path) * `stub/4` to install specific routes without expectations +* `expect/2` and `expect/4` well set up a called *at least once* expectaton. +* `expect_once/2` and `expect_once/4` setup a called *exactly once* expecation. +* `expect/3` and `expect/5` setup a called *exactly n times* expectation. + * a combination of the above, where the routes will be used first, and then the generic version will be used as default diff --git a/lib/bypass.ex b/lib/bypass.ex index 4d7ae77..c66bbbb 100644 --- a/lib/bypass.ex +++ b/lib/bypass.ex @@ -109,11 +109,8 @@ defmodule Bypass do :ok_call -> :ok - {:error, :too_many_requests, {:any, :any}} -> - raise error_module, "Expected only one HTTP request for Bypass" - - {:error, :too_many_requests, {method, path}} -> - raise error_module, "Expected only one HTTP request for Bypass at #{method} #{path}" + {:error, :too_many_requests, route_tuple, expected_actual_tuple} -> + raise error_module, format_too_many_requests_message(route_tuple, expected_actual_tuple) {:error, :unexpected_request, {:any, :any}} -> raise error_module, "Bypass got an HTTP request but wasn't expecting one" @@ -134,6 +131,23 @@ defmodule Bypass do end end + defp format_too_many_requests_message(route, {expected, actual}) do + expected_language = + case expected do + 0 -> "no HTTP requests" + 1 -> "only 1 HTTP request" + plural -> "#{plural} HTTP requests" + end + + route_language = + case route do + {:any, :any} -> "" + {method, path} -> " at #{method} #{path}" + end + + "Expected #{expected_language} for Bypass#{route_language}, but got #{actual}" + end + @doc """ Re-opens the TCP socket on the same port. Blocks until the operation is complete. @@ -172,6 +186,21 @@ defmodule Bypass do def expect(%Bypass{pid: pid}, fun), do: Bypass.Instance.call(pid, {:expect, fun}) + @doc """ + Expects the passed function to be called an exact number of times, regardless of the route. + + ```elixir + Bypass.expect(bypass, 2, fn conn -> + assert "/1.1/statuses/update.json" == conn.request_path + assert "POST" == conn.method + Plug.Conn.resp(conn, 429, ~s<{"errors": [{"code": 88, "message": "Rate limit exceeded"}]}>) + end) + ``` + """ + @spec expect(Bypass.t(), pos_integer(), (Plug.Conn.t() -> Plug.Conn.t())) :: :ok + def expect(%Bypass{pid: pid}, count, fun), + do: Bypass.Instance.call(pid, {count, fun}) + @doc """ Expects the passed function to be called at least once for the specified route (method and path). @@ -190,6 +219,25 @@ defmodule Bypass do def expect(%Bypass{pid: pid}, method, path, fun), do: Bypass.Instance.call(pid, {:expect, method, path, fun}) + @doc """ + Expects the passed function to be called an exact number of times for the specified route (method and path). + + - `method` is one of `["GET", "POST", "HEAD", "PUT", "PATCH", "DELETE", "OPTIONS", "CONNECT"]` + + - `path` is the endpoint. + + ```elixir + Bypass.expect(bypass, "POST", "/1.1/statuses/update.json", 2, fn conn -> + Agent.get_and_update(AgentModule, fn step_no -> {step_no, step_no + 1} end) + Plug.Conn.resp(conn, 200, "") + end) + ``` + """ + @spec expect(Bypass.t(), String.t(), String.t(), integer(), (Plug.Conn.t() -> Plug.Conn.t())) :: + :ok + def expect(%Bypass{pid: pid}, method, path, count, fun), + do: Bypass.Instance.call(pid, {count, method, path, fun}) + @doc """ Expects the passed function to be called exactly once regardless of the route. diff --git a/lib/bypass/instance.ex b/lib/bypass/instance.ex index 9426c00..283d6f6 100644 --- a/lib/bypass/instance.ex +++ b/lib/bypass/instance.ex @@ -104,7 +104,8 @@ defmodule Bypass.Instance do end end - defp do_handle_call({expect, fun}, from, state) when expect in [:expect, :expect_once] do + defp do_handle_call({expect, fun}, from, state) + when expect in [:expect, :expect_once] or is_integer(expect) do do_handle_call({expect, :any, :any, fun}, from, state) end @@ -113,7 +114,7 @@ defmodule Bypass.Instance do _from, %{expectations: expectations} = state ) - when expect in [:stub, :expect, :expect_once] and + when (expect in [:stub, :expect, :expect_once] or is_integer(expect)) and method in [ "GET", "POST", @@ -138,8 +139,9 @@ defmodule Bypass.Instance do path, case expect do :expect -> :once_or_more - :expect_once -> :once + :expect_once -> 1 :stub -> :none_or_more + call_count when is_integer(call_count) -> call_count end ) ) @@ -174,8 +176,10 @@ defmodule Bypass.Instance do %{expectations: expectations} = state ) do case Map.get(expectations, route) do - %{expected: :once, request_count: count} when count > 0 -> - {:reply, {:error, :too_many_requests, route}, increase_route_count(state, route)} + %{expected: expected, request_count: count} + when is_integer(expected) and count >= expected -> + state = increase_route_count(state, route) + {:reply, {:error, :too_many_requests, route, {expected, count + 1}}, state} nil -> {:reply, {:error, :unexpected_request, route}, state} @@ -253,28 +257,38 @@ defmodule Bypass.Instance do problem_route = expectations |> Enum.reject(fn {_route, expectations} -> expectations[:expected] == :none_or_more end) + |> Enum.reject(fn {_route, %{expected: expected, request_count: actual}} -> + expected == 0 and actual == 0 + end) |> Enum.find(fn {_route, expectations} -> Enum.empty?(expectations.results) end) case problem_route do - {route, _} -> + {_route, %{expected: 0, request_count: actual}} when actual > 0 -> + find_error(expectations) + + {route, _arg} -> {:error, :not_called, route} nil -> - Enum.reduce_while(expectations, nil, fn {_route, route_expectations}, _ -> - first_error = - Enum.find(route_expectations.results, fn - result when is_tuple(result) -> result - _result -> nil - end) - - case first_error do - nil -> {:cont, nil} - error -> {:halt, error} - end - end) + find_error(expectations) end end + defp find_error(expectations) do + Enum.reduce_while(expectations, nil, fn {_route, route_expectations}, _ -> + first_error = + Enum.find(route_expectations.results, fn + result when is_tuple(result) -> result + _result -> nil + end) + + case first_error do + nil -> {:cont, nil} + error -> {:halt, error} + end + end) + end + defp route_info(method, path, %{expectations: expectations} = _state) do segments = build_path_match(path) |> elem(1) diff --git a/lib/bypass/plug.ex b/lib/bypass/plug.ex index ce126dd..15e5d01 100644 --- a/lib/bypass/plug.ex +++ b/lib/bypass/plug.ex @@ -30,6 +30,10 @@ defmodule Bypass.Plug do {:error, error, route} -> put_result(pid, route, make_ref(), {:error, error, route}) raise "route error" + + {:error, error, route, counts} -> + put_result(pid, route, make_ref(), {:error, error, route, counts}) + raise "route error" end end diff --git a/test/bypass_test.exs b/test/bypass_test.exs index 6f41000..67bf4c4 100644 --- a/test/bypass_test.exs +++ b/test/bypass_test.exs @@ -289,7 +289,7 @@ defmodule BypassTest do # Override Bypass' on_exit handler. ExUnit.Callbacks.on_exit({Bypass, bypass.pid}, fn -> exit_result = Bypass.Instance.call(bypass.pid, :on_exit) - assert {:error, :too_many_requests, {:any, :any}} = exit_result + assert {:error, :too_many_requests, {:any, :any}, {1, 5}} = exit_result end) end @@ -309,6 +309,47 @@ defmodule BypassTest do :expect_once |> specific_route end + test "Bypass.expect can be used to specify an expected call count" do + bypass = Bypass.open() + + Bypass.expect(bypass, "POST", "/this", 2, fn conn -> + Plug.Conn.send_resp(conn, 200, "") + end) + + assert {:ok, 200, ""} = request(bypass.port, "/this") + assert {:ok, 200, ""} = request(bypass.port, "/this") + # This one will fail, because it is over the limit + assert {:ok, 500, ""} = request(bypass.port, "/this") + # Override Bypass' on_exit handler. + ExUnit.Callbacks.on_exit({Bypass, bypass.pid}, fn -> + exit_result = Bypass.Instance.call(bypass.pid, :on_exit) + assert {:error, :too_many_requests, {"POST", "/this"}, {2, 3}} = exit_result + end) + end + + test "Bypass.expect can be used to specify not called, passes when not called" do + bypass = Bypass.open() + + Bypass.expect(bypass, "POST", "/this", 0, fn _conn -> + raise "Should be not called" + end) + end + + test "Bypass.expect can be used to specify not called, fails when called" do + bypass = Bypass.open() + + Bypass.expect(bypass, "POST", "/this", 0, fn conn -> + Plug.Conn.send_resp(conn, 200, "This should fail verify because it was called") + end) + + _ = request(bypass.port, "/this") + + ExUnit.Callbacks.on_exit({Bypass, bypass.pid}, fn -> + exit_result = Bypass.Instance.call(bypass.pid, :on_exit) + assert {:error, :too_many_requests, {"POST", "/this"}, {0, 1}} = exit_result + end) + end + defp set_expectation(action, path) do bypass = Bypass.open() method = "POST" @@ -575,7 +616,7 @@ defmodule BypassTest do assert {:ok, 200, ""} = request(bypass.port, "/foo", "GET") :timer.sleep(10) - assert_raise ESpec.AssertionError, "Expected only one HTTP request for Bypass", fn -> + assert_raise ESpec.AssertionError, "Expected only 1 HTTP request for Bypass, but got 2", fn -> Bypass.verify_expectations!(bypass) end