From ca7cf04f7a65f096b19ac581bba1b02db997288a Mon Sep 17 00:00:00 2001 From: Rudolf Manusadzhian Date: Fri, 18 Oct 2024 12:56:51 -0500 Subject: [PATCH] Support expectation on exact number of requests (#128) * support expectation on exact number of requests * regroup guard * document feature * Use Agent.update instead of get_and_update in examples --- lib/bypass.ex | 48 +++++++++++++++++++++++++++++++++--- lib/bypass/instance.ex | 25 +++++++++++++++++-- test/bypass_test.exs | 56 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 5 deletions(-) diff --git a/lib/bypass.ex b/lib/bypass.ex index 4d7ae77..afd6c72 100644 --- a/lib/bypass.ex +++ b/lib/bypass.ex @@ -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" @@ -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). @@ -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) ``` @@ -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. @@ -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) ``` @@ -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) ``` diff --git a/lib/bypass/instance.ex b/lib/bypass/instance.ex index 9426c00..f8ae2b1 100644 --- a/lib/bypass/instance.ex +++ b/lib/bypass/instance.ex @@ -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", @@ -140,6 +145,7 @@ defmodule Bypass.Instance do :expect -> :once_or_more :expect_once -> :once :stub -> :none_or_more + {:exactly, n} -> {:exactly, n} end ) ) @@ -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} @@ -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} @@ -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) diff --git a/test/bypass_test.exs b/test/bypass_test.exs index 7f247c8..8c26cc8 100644 --- a/test/bypass_test.exs +++ b/test/bypass_test.exs @@ -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