diff --git a/lib/test_server.ex b/lib/test_server.ex index b37bfd1..99e2d7f 100644 --- a/lib/test_server.ex +++ b/lib/test_server.ex @@ -117,7 +117,8 @@ defmodule TestServer do defp verify_routes!(instance) do instance |> Instance.routes() - |> Enum.reject(& &1.suspended) + |> Enum.filter(&Instance.active_route?/1) + |> Enum.reject(&(&1.times == :infinity)) |> case do [] -> :ok @@ -134,7 +135,8 @@ defmodule TestServer do defp verify_websocket_handlers!(instance) do instance |> Instance.websocket_handlers() - |> Enum.reject(& &1.suspended) + |> Enum.filter(&Instance.active_websocket_handler?/1) + |> Enum.reject(&(&1.times == :infinity)) |> case do [] -> :ok diff --git a/lib/test_server/instance.ex b/lib/test_server/instance.ex index a1ae54a..9336811 100644 --- a/lib/test_server/instance.ex +++ b/lib/test_server/instance.ex @@ -193,6 +193,7 @@ defmodule TestServer.Instance do match = Keyword.get_lazy(options, :match, fn -> build_match_function(uri, methods) end) to = Keyword.fetch!(options, :to) + times = Keyword.get(options, :times, 1) route = %{ ref: make_ref(), @@ -200,10 +201,10 @@ defmodule TestServer.Instance do methods: methods, match: match, to: to, - options: Keyword.drop(options, [:via, :match, :to]), + options: Keyword.drop(options, [:via, :match, :to, :times]), requests: [], stacktrace: stacktrace, - suspended: false + times: times } {:reply, {:ok, route}, %{state | routes: state.routes ++ [route]}} @@ -220,14 +221,16 @@ defmodule TestServer.Instance do _from, state ) do + times = Keyword.get(options, :times, 1) + handler = %{ route_ref: route_ref, match: Keyword.get(options, :match), to: Keyword.fetch!(options, :to), - options: Keyword.drop(options, [:match, :to]), + options: Keyword.drop(options, [:match, :to, :times]), received: [], stacktrace: stacktrace, - suspended: false + times: times } {:reply, {:ok, handler}, %{state | websocket_handlers: state.websocket_handlers ++ [handler]}} @@ -282,6 +285,9 @@ defmodule TestServer.Instance do {:reply, res, state} end + def active_route?(route), do: route.times == :infinity or route.times > 0 + def active_websocket_handler?(handler), do: handler.times == :infinity or handler.times > 0 + defp build_match_function(uri, methods) do {method_match, guards} = case methods do @@ -359,7 +365,7 @@ defmodule TestServer.Instance do defp run_routes(conn, state) do state.routes |> Enum.find_index(fn - %{suspended: true} -> false + %{times: 0} -> false %{match: match} -> try_run_match(match, [conn]) end) |> case do @@ -367,7 +373,7 @@ defmodule TestServer.Instance do {{:error, {:not_found, conn}}, state} index -> - %{to: plug, stacktrace: stacktrace} = route = Enum.at(state.routes, index) + %{to: plug, stacktrace: stacktrace, times: times} = route = Enum.at(state.routes, index) result = conn @@ -376,7 +382,8 @@ defmodule TestServer.Instance do routes = List.update_at(state.routes, index, fn route -> - %{route | suspended: true, requests: route.requests ++ [result]} + new_times = if times == :infinity, do: :infinity, else: times - 1 + %{route | times: new_times, requests: route.requests ++ [result]} end) {result, %{state | routes: routes}} @@ -405,7 +412,7 @@ defmodule TestServer.Instance do |> Enum.map(&{&1.route_ref == route_ref, &1}) |> Enum.find_index(fn {false, _} -> false - {true, %{suspended: true}} -> false + {true, %{times: 0}} -> false {true, %{match: nil}} -> true {true, %{match: match}} -> try_run_match(match, [frame, websocket_state]) end) @@ -414,13 +421,14 @@ defmodule TestServer.Instance do {{:error, :not_found}, state} index -> - %{to: handler, stacktrace: stacktrace} = Enum.at(state.websocket_handlers, index) + %{to: handler, stacktrace: stacktrace, times: times} = Enum.at(state.websocket_handlers, index) result = try_run_websocket_handler(frame, websocket_state, stacktrace, handler) websocket_handlers = List.update_at(state.websocket_handlers, index, fn websocket_handle -> - %{websocket_handle | suspended: true, received: websocket_handle.received ++ [frame]} + new_times = if times == :infinity, do: :infinity, else: times - 1 + %{websocket_handle | times: new_times, received: websocket_handle.received ++ [frame]} end) {result, %{state | websocket_handlers: websocket_handlers}} diff --git a/lib/test_server/plug.ex b/lib/test_server/plug.ex index 6642022..9c4fef2 100644 --- a/lib/test_server/plug.ex +++ b/lib/test_server/plug.ex @@ -43,7 +43,7 @@ defmodule TestServer.Plug do end defp append_formatted_routes(message, instance) do - routes = Enum.split_with(Instance.routes(instance), &(not &1.suspended)) + routes = Enum.split_with(Instance.routes(instance), &Instance.active_route?/1) """ #{message} @@ -52,23 +52,23 @@ defmodule TestServer.Plug do """ end - defp format_routes({[], suspended_routes}) do + defp format_routes({[], exhausted_routes}) do message = "No active routes." - case suspended_routes do + case exhausted_routes do [] -> message - suspended_routes -> + exhausted_routes -> """ #{message} The following routes have been processed: - #{Instance.format_routes(suspended_routes)} + #{Instance.format_routes(exhausted_routes)} """ end end - defp format_routes({active_routes, _suspended_routes}) do + defp format_routes({active_routes, _exhausted_routes}) do """ Active routes: diff --git a/lib/test_server/websocket.ex b/lib/test_server/websocket.ex index 9c219d9..fd2768f 100644 --- a/lib/test_server/websocket.ex +++ b/lib/test_server/websocket.ex @@ -39,7 +39,7 @@ defmodule TestServer.WebSocket do websocket_handlers = Instance.websocket_handlers(instance) |> Enum.filter(&(&1.route_ref == route_ref)) - |> Enum.split_with(&(not &1.suspended)) + |> Enum.split_with(&Instance.active_websocket_handler?/1) """ #{message} @@ -48,10 +48,10 @@ defmodule TestServer.WebSocket do """ end - defp format_websocket_handlers({[], suspended_websocket_handlers}) do + defp format_websocket_handlers({[], exhausted_websocket_handlers}) do message = "No active websocket handlers." - case suspended_websocket_handlers do + case exhausted_websocket_handlers do [] -> message @@ -59,7 +59,7 @@ defmodule TestServer.WebSocket do """ #{message} The following websocket handlers have been processed: - #{Instance.format_routes(websocket_handlers)}" + #{Instance.format_websocket_handlers(websocket_handlers)}" """ end end diff --git a/test/test_server_test.exs b/test/test_server_test.exs index 61d7fe8..4d35fb5 100644 --- a/test/test_server_test.exs +++ b/test/test_server_test.exs @@ -812,6 +812,322 @@ defmodule TestServerTest do end end + describe "times option for HTTP routes" do + test "defaults to times: 1 (single use)" do + defmodule DefaultSingleUseTest do + use ExUnit.Case + + test "passes" do + {:ok, _instance} = TestServer.start(suppress_warning: true) + + assert :ok = TestServer.add("/single", to: &Plug.Conn.resp(&1, 200, "first")) + + # First request should succeed + assert {:ok, "first"} = unquote(__MODULE__).http1_request(TestServer.url("/single")) + + # Second request should fail as route is now inactive + assert {:error, _} = unquote(__MODULE__).http1_request(TestServer.url("/single")) + end + end + + capture_io(fn -> ExUnit.run() end) + end + + test "with times: 0 makes route immediately inactive" do + defmodule TimesZeroTest do + use ExUnit.Case + + test "passes" do + {:ok, _instance} = TestServer.start(suppress_warning: true) + + assert :ok = TestServer.add("/never", times: 0, to: &Plug.Conn.resp(&1, 200, "never")) + + # Route should be inactive from the start + assert {:error, _} = unquote(__MODULE__).http1_request(TestServer.url("/never")) + end + end + + capture_io(fn -> ExUnit.run() end) + end + + test "with times: 2 allows multiple uses" do + defmodule TimesTwoTest do + use ExUnit.Case + + test "passes" do + {:ok, _instance} = TestServer.start(suppress_warning: true) + + assert :ok = TestServer.add("/twice", times: 2, to: &Plug.Conn.resp(&1, 200, "twice")) + + # First two requests should succeed + assert {:ok, "twice"} = unquote(__MODULE__).http1_request(TestServer.url("/twice")) + assert {:ok, "twice"} = unquote(__MODULE__).http1_request(TestServer.url("/twice")) + + # Third request should fail + assert {:error, _} = unquote(__MODULE__).http1_request(TestServer.url("/twice")) + end + end + + capture_io(fn -> ExUnit.run() end) + end + + test "with times: :infinity allows unlimited uses" do + TestServer.start() + + assert :ok = TestServer.add("/forever", times: :infinity, to: &Plug.Conn.resp(&1, 200, "forever")) + + # Should work multiple times + for _ <- 1..5 do + assert {:ok, "forever"} = http1_request(TestServer.url("/forever")) + end + end + + test "routes are matched FIFO with times consideration" do + defmodule FIFOOrderTest do + use ExUnit.Case + + test "passes" do + {:ok, _instance} = TestServer.start(suppress_warning: true) + + # First route with times: 1 + assert :ok = TestServer.add("/order", times: 1, to: &Plug.Conn.resp(&1, 200, "first")) + # Second route with times: 1 + assert :ok = TestServer.add("/order", times: 1, to: &Plug.Conn.resp(&1, 200, "second")) + + # First request matches first route + assert {:ok, "first"} = unquote(__MODULE__).http1_request(TestServer.url("/order")) + # Second request matches second route (first is now inactive) + assert {:ok, "second"} = unquote(__MODULE__).http1_request(TestServer.url("/order")) + # Third request fails (both routes now inactive) + assert {:error, _} = unquote(__MODULE__).http1_request(TestServer.url("/order")) + end + end + + capture_io(fn -> ExUnit.run() end) + end + + test "unused routes with finite times cause test failure" do + defmodule UnusedRouteTimesTest do + use ExUnit.Case + + test "fails" do + {:ok, _instance} = TestServer.start(suppress_warning: true) + TestServer.add("/unused", times: 2, to: &Plug.Conn.resp(&1, 200, "unused")) + # Route is not called, should cause test failure + end + end + + assert capture_io(fn -> ExUnit.run() end) =~ + "did not receive a request for these routes before the test ended" + end + + test "unused routes with times: :infinity do not cause test failure" do + defmodule UnusedInfiniteRouteTest do + use ExUnit.Case + + test "passes" do + {:ok, _instance} = TestServer.start(suppress_warning: true) + TestServer.add("/unused", times: :infinity, to: &Plug.Conn.resp(&1, 200, "unused")) + # Route is not called, but should not cause test failure + end + end + + output = capture_io(fn -> ExUnit.run() end) + refute output =~ "did not receive a request for these routes before the test ended" + end + + test "times counter decrements on each matching request" do + defmodule TimesCounterTest do + use ExUnit.Case + + test "passes" do + {:ok, _instance} = TestServer.start(suppress_warning: true) + + # Add a route that tracks how many times it's called + call_count = Agent.start_link(fn -> 0 end) + {:ok, agent} = call_count + + assert :ok = TestServer.add("/counter", times: 3, to: fn conn -> + Agent.update(agent, &(&1 + 1)) + count = Agent.get(agent, & &1) + Plug.Conn.resp(conn, 200, "call #{count}") + end) + + # Make 3 requests, each should work and increment counter + assert {:ok, "call 1"} = unquote(__MODULE__).http1_request(TestServer.url("/counter")) + assert {:ok, "call 2"} = unquote(__MODULE__).http1_request(TestServer.url("/counter")) + assert {:ok, "call 3"} = unquote(__MODULE__).http1_request(TestServer.url("/counter")) + + # 4th request should fail + assert {:error, _} = unquote(__MODULE__).http1_request(TestServer.url("/counter")) + + # Verify we called exactly 3 times + assert Agent.get(agent, & &1) == 3 + end + end + + capture_io(fn -> ExUnit.run() end) + end + end + + unless System.get_env("HTTP_SERVER") == "Httpd" do + describe "times option for WebSocket handlers" do + test "defaults to times: 1 (single use)" do + TestServer.start() + + assert {:ok, socket} = TestServer.websocket_init("/ws") + assert {:ok, client} = WebSocketClient.start_link(TestServer.url("/ws")) + + assert :ok = TestServer.websocket_handle(socket, to: fn _frame, state -> + {:reply, {:text, "single"}, state} + end) + + # First message should work + assert WebSocketClient.send_message(client, "test") == {:ok, "single"} + end + + test "with times: 2 allows multiple uses" do + TestServer.start() + + assert {:ok, socket} = TestServer.websocket_init("/ws") + assert {:ok, client} = WebSocketClient.start_link(TestServer.url("/ws")) + + assert :ok = TestServer.websocket_handle(socket, times: 2, to: fn _frame, state -> + {:reply, {:text, "twice"}, state} + end) + + assert WebSocketClient.send_message(client, "test1") == {:ok, "twice"} + assert WebSocketClient.send_message(client, "test2") == {:ok, "twice"} + end + + test "with times: :infinity allows unlimited uses" do + TestServer.start() + + assert {:ok, socket} = TestServer.websocket_init("/ws") + assert {:ok, client} = WebSocketClient.start_link(TestServer.url("/ws")) + + assert :ok = TestServer.websocket_handle(socket, times: :infinity, to: fn _frame, state -> + {:reply, {:text, "forever"}, state} + end) + + # Should work multiple times + for i <- 1..5 do + assert WebSocketClient.send_message(client, "test#{i}") == {:ok, "forever"} + end + end + + test "handlers are matched FIFO with times consideration" do + assert {:ok, socket} = TestServer.websocket_init("/ws") + assert {:ok, client} = WebSocketClient.start_link(TestServer.url("/ws")) + + assert :ok = TestServer.websocket_handle(socket, times: 1, to: fn _frame, state -> + {:reply, {:text, "first"}, state} + end) + + assert :ok = TestServer.websocket_handle(socket, times: 1, to: fn _frame, state -> + {:reply, {:text, "second"}, state} + end) + + assert WebSocketClient.send_message(client, "test1") == {:ok, "first"} + assert WebSocketClient.send_message(client, "test2") == {:ok, "second"} + end + + test "unused handlers with finite times cause test failure" do + defmodule UnusedWebSocketHandlerTimesTest do + use ExUnit.Case + + test "fails" do + {:ok, _instance} = TestServer.start(suppress_warning: true) + {:ok, socket} = TestServer.websocket_init("/ws") + {:ok, _client} = WebSocketClient.start_link(TestServer.url("/ws")) + + TestServer.websocket_handle(socket, times: 2, to: fn _frame, state -> + {:reply, {:text, "unused"}, state} + end) + # Handler is not called, should cause test failure + end + end + + assert capture_io(fn -> ExUnit.run() end) =~ + "did not receive a frame for these websocket handlers before the test ended" + end + + test "unused handlers with times: :infinity do not cause test failure" do + defmodule UnusedInfiniteWebSocketHandlerTest do + use ExUnit.Case + + test "passes" do + {:ok, _instance} = TestServer.start(suppress_warning: true) + {:ok, socket} = TestServer.websocket_init("/ws") + {:ok, _client} = WebSocketClient.start_link(TestServer.url("/ws")) + + TestServer.websocket_handle(socket, times: :infinity, to: fn _frame, state -> + {:reply, {:text, "unused"}, state} + end) + # Handler is not called, but should not cause test failure + end + end + + output = capture_io(fn -> ExUnit.run() end) + refute output =~ "did not receive a frame for these websocket handlers before the test ended" + end + + test "times counter decrements on each matching frame" do + TestServer.start() + + # Create agent to track call count + call_count = Agent.start_link(fn -> 0 end) + {:ok, agent} = call_count + + assert {:ok, socket} = TestServer.websocket_init("/ws") + assert {:ok, client} = WebSocketClient.start_link(TestServer.url("/ws")) + + assert :ok = TestServer.websocket_handle(socket, times: 3, to: fn _frame, state -> + Agent.update(agent, &(&1 + 1)) + count = Agent.get(agent, & &1) + {:reply, {:text, "call #{count}"}, state} + end) + + # Make 3 messages, each should work and increment counter + assert WebSocketClient.send_message(client, "test1") == {:ok, "call 1"} + assert WebSocketClient.send_message(client, "test2") == {:ok, "call 2"} + assert WebSocketClient.send_message(client, "test3") == {:ok, "call 3"} + + # Verify we called exactly 3 times + assert Agent.get(agent, & &1) == 3 + end + + test "times with match function" do + TestServer.start() + + assert {:ok, socket} = TestServer.websocket_init("/ws") + assert {:ok, client} = WebSocketClient.start_link(TestServer.url("/ws")) + + # Handler that only matches "ping" messages, limited to 2 times + assert :ok = TestServer.websocket_handle(socket, + times: 2, + match: fn {:text, "ping"}, _state -> true; _, _ -> false end, + to: fn _frame, state -> {:reply, {:text, "pong"}, state} end + ) + + # Default handler for other messages (unlimited) + assert :ok = TestServer.websocket_handle(socket, times: :infinity, to: fn frame, state -> + {:reply, frame, state} + end) + + # "ping" should work twice + assert WebSocketClient.send_message(client, "ping") == {:ok, "pong"} + assert WebSocketClient.send_message(client, "ping") == {:ok, "pong"} + + # Third "ping" should fall through to default handler (echo) + assert WebSocketClient.send_message(client, "ping") == {:ok, "ping"} + + # Other messages should always work with default handler + assert WebSocketClient.send_message(client, "hello") == {:ok, "hello"} + end + end + end + def http1_request(url, opts \\ []) do url = String.to_charlist(url) httpc_http_opts = Keyword.get(opts, :http_opts, [])