Skip to content

Commit

Permalink
Support expectation on exact number of requests (#128)
Browse files Browse the repository at this point in the history
* support expectation on exact number of requests

* regroup guard

* document feature

* Use Agent.update instead of get_and_update in examples
  • Loading branch information
RudolfMan authored Oct 18, 2024
1 parent 8e5b58e commit ca7cf04
Show file tree
Hide file tree
Showing 3 changed files with 124 additions and 5 deletions.
48 changes: 45 additions & 3 deletions lib/bypass.ex
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,13 @@ defmodule Bypass do
{:error, :too_many_requests, {method, path}} ->
raise error_module, "Expected only one HTTP request for Bypass at #{method} #{path}"

{:error, {:unexpected_request_number, expected, actual}, {:any, :any}} ->
raise error_module, "Expected #{expected} HTTP request for Bypass, got #{actual}"

{:error, {:unexpected_request_number, expected, actual}, {method, path}} ->
raise error_module,
"Expected #{expected} HTTP request for Bypass at #{method} #{path}, got #{actual}"

{:error, :unexpected_request, {:any, :any}} ->
raise error_module, "Bypass got an HTTP request but wasn't expecting one"

Expand Down Expand Up @@ -172,6 +179,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 exactly `n` times for any route.
```elixir
Bypass.expect(bypass, 3, 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}, n, fun),
do: Bypass.Instance.call(pid, {:expect, n, fun})

@doc """
Expects the passed function to be called at least once for the specified route (method and path).
Expand All @@ -181,7 +203,7 @@ defmodule Bypass do
```elixir
Bypass.expect(bypass, "POST", "/1.1/statuses/update.json", fn conn ->
Agent.get_and_update(AgentModule, fn step_no -> {step_no, step_no + 1} end)
Agent.update(AgentModule, fn step_no -> step_no + 1 end)
Plug.Conn.resp(conn, 429, ~s<{"errors": [{"code": 88, "message": "Rate limit exceeded"}]}>)
end)
```
Expand All @@ -190,6 +212,26 @@ 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 exactly `n` times for the specified route (method and path).
- `method` is one of `["GET", "POST", "HEAD", "PUT", "PATCH", "DELETE", "OPTIONS", "CONNECT"]`
- `path` is the endpoint.
- `n` is the number of times the route is expected to be called.
```elixir
Bypass.expect(bypass, "POST", "/1.1/statuses/update.json", 3, fn conn ->
Agent.update(AgentModule, fn step_no -> step_no + 1 end)
Plug.Conn.resp(conn, 429, ~s<{"errors": [{"code": 88, "message": "Rate limit exceeded"}]}>)
end)
```
"""
@spec expect(Bypass.t(), String.t(), String.t(), pos_integer(), (Plug.Conn.t() -> Plug.Conn.t())) :: :ok
def expect(%Bypass{pid: pid}, method, path, n, fun),
do: Bypass.Instance.call(pid, {{:exactly, n}, method, path, fun})

@doc """
Expects the passed function to be called exactly once regardless of the route.
Expand All @@ -214,7 +256,7 @@ defmodule Bypass do
```elixir
Bypass.expect_once(bypass, "POST", "/1.1/statuses/update.json", fn conn ->
Agent.get_and_update(AgentModule, fn step_no -> {step_no, step_no + 1} end)
Agent.update(AgentModule, fn step_no -> step_no + 1 end)
Plug.Conn.resp(conn, 429, ~s<{"errors": [{"code": 88, "message": "Rate limit exceeded"}]}>)
end)
```
Expand All @@ -232,7 +274,7 @@ defmodule Bypass do
```elixir
Bypass.stub(bypass, "POST", "/1.1/statuses/update.json", fn conn ->
Agent.get_and_update(AgentModule, fn step_no -> {step_no, step_no + 1} end)
Agent.update(AgentModule, fn step_no -> step_no + 1 end)
Plug.Conn.resp(conn, 429, ~s<{"errors": [{"code": 88, "message": "Rate limit exceeded"}]}>)
end)
```
Expand Down
25 changes: 23 additions & 2 deletions lib/bypass/instance.ex
Original file line number Diff line number Diff line change
Expand Up @@ -108,12 +108,17 @@ defmodule Bypass.Instance do
do_handle_call({expect, :any, :any, fun}, from, state)
end

defp do_handle_call({:expect, n, fun}, from, state) do
do_handle_call({{:exactly, n}, :any, :any, fun}, from, state)
end

defp do_handle_call(
{expect, method, path, fun},
_from,
%{expectations: expectations} = state
)
when expect in [:stub, :expect, :expect_once] and
when (expect in [:stub, :expect, :expect_once] or
(is_tuple(expect) and elem(expect, 0) == :exactly)) and
method in [
"GET",
"POST",
Expand All @@ -140,6 +145,7 @@ defmodule Bypass.Instance do
:expect -> :once_or_more
:expect_once -> :once
:stub -> :none_or_more
{:exactly, n} -> {:exactly, n}
end
)
)
Expand Down Expand Up @@ -177,6 +183,10 @@ defmodule Bypass.Instance do
%{expected: :once, request_count: count} when count > 0 ->
{:reply, {:error, :too_many_requests, route}, increase_route_count(state, route)}

%{expected: {:exactly, n}, request_count: count} when count >= n ->
{:reply, {:error, {:unexpected_request_number, n, count + 1}, route},
increase_route_count(state, route)}

nil ->
{:reply, {:error, :unexpected_request, route}, state}

Expand Down Expand Up @@ -253,9 +263,12 @@ defmodule Bypass.Instance do
problem_route =
expectations
|> Enum.reject(fn {_route, expectations} -> expectations[:expected] == :none_or_more end)
|> Enum.find(fn {_route, expectations} -> Enum.empty?(expectations.results) end)
|> Enum.find(fn {_route, expectations} -> problem_route?(expectations) end)

case problem_route do
{route, %{expected: {:exactly, expected}, request_count: actual}} ->
{:error, {:unexpected_request_number, expected, actual}, route}

{route, _} ->
{:error, :not_called, route}

Expand All @@ -275,6 +288,14 @@ defmodule Bypass.Instance do
end
end

defp problem_route?(%{expected: {:exactly, n}} = expectations) do
length(expectations.results) < n
end

defp problem_route?(expectations) do
Enum.empty?(expectations.results)
end

defp route_info(method, path, %{expectations: expectations} = _state) do
segments = build_path_match(path) |> elem(1)

Expand Down
56 changes: 56 additions & 0 deletions test/bypass_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,62 @@ defmodule BypassTest do
end)
end

for {expected, actual, alt} <- [{3, 5, "too many"}, {5, 3, "not enough"}] do
@tag expected: expected, actual: actual
test "Bypass.expect/3 fails when #{alt} requests arrived", %{
expected: expected,
actual: actual
} do
bypass = Bypass.open()
parent = self()

Bypass.expect(bypass, expected, fn conn ->
send(parent, :request_received)
Plug.Conn.send_resp(conn, 200, "")
end)

Enum.map(1..actual, fn _ -> Task.async(fn -> request(bypass.port) end) end)
|> Task.await_many()

Enum.each(1..min(actual, expected), fn _ -> assert_receive :request_received end)
refute_receive :request_received

# Override Bypass' on_exit handler.
ExUnit.Callbacks.on_exit({Bypass, bypass.pid}, fn ->
exit_result = Bypass.Instance.call(bypass.pid, :on_exit)
assert {:error, {:unexpected_request_number, expected, actual}, _} = exit_result
assert expected != actual
end)
end

@tag expected: expected, actual: actual
test "Bypass.expect/5 fails when #{alt} requests arrived", %{
expected: expected,
actual: actual
} do
bypass = Bypass.open()
parent = self()

Bypass.expect(bypass, "GET", "/foo", expected, fn conn ->
send(parent, :request_received)
Plug.Conn.send_resp(conn, 200, "")
end)

Enum.map(1..actual, fn _ -> Task.async(fn -> request(bypass.port, "/foo", "GET") end) end)
|> Task.await_many()

Enum.each(1..min(actual, expected), fn _ -> assert_receive :request_received end)
refute_receive :request_received

# Override Bypass' on_exit handler.
ExUnit.Callbacks.on_exit({Bypass, bypass.pid}, fn ->
exit_result = Bypass.Instance.call(bypass.pid, :on_exit)
assert {:error, {:unexpected_request_number, expected, actual}, _} = exit_result
assert expected != actual
end)
end
end

test "Bypass.stub/4 does not raise if request is made" do
:stub |> specific_route
end
Expand Down

0 comments on commit ca7cf04

Please sign in to comment.