Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow the user to specify the expected number of calls #140

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
58 changes: 53 additions & 5 deletions lib/bypass.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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.
Expand Down Expand Up @@ -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).

Expand All @@ -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.

Expand Down
50 changes: 32 additions & 18 deletions lib/bypass/instance.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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",
Expand All @@ -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
)
)
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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)

Expand Down
4 changes: 4 additions & 0 deletions lib/bypass/plug.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
45 changes: 43 additions & 2 deletions test/bypass_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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"
Expand Down Expand Up @@ -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

Expand Down